feat: добавить управление окружениями и Playwright для E2E

This commit is contained in:
sokol
2026-02-18 14:04:03 +03:00
parent 8f0112526f
commit 4b8f5f3739
11 changed files with 464 additions and 28 deletions

View File

@@ -22,6 +22,11 @@ class AppState {
this.config = cfg;
}
public addEnv(env: Env): number {
this.envs.push(env);
return this.envs.length - 1;
}
public async saveEnv(env: Env): Promise<number> {
// Create a promise that resolves after 1 second
@@ -43,11 +48,37 @@ function App() {
const [selectedEnv, setSelectedEnv] = useState(0);
const [config, setConfig] = useState(AppState.Instance.config);
// Ensure selectedEnv is always valid
const validSelectedEnv = Math.min(selectedEnv, Math.max(0, envs.length - 1));
const currentEnv = envs[validSelectedEnv];
async function handleEnvChanged(env: Env) {
let idx = await AppState.Instance.saveEnv(env);
// Synchronously update the env in the array
let idx = AppState.Instance.envs.findIndex(x => x.id === env.id);
if (idx > -1) {
AppState.Instance.envs[idx] = env;
setEnvs([...AppState.Instance.envs]);
}
// Then do the async save (for consistency with existing behavior)
await AppState.Instance.saveEnv(env);
}
function handleEnvSelected(idx: number) {
setSelectedEnv(idx);
}
function handleEnvAdded(env: Env): number {
const idx = AppState.Instance.addEnv(env);
setEnvs([...AppState.Instance.envs]);
return idx;
}
function handleEnvRemoved(envId: number) {
const idx = AppState.Instance.envs.findIndex(x => x.id === envId);
if (idx > -1) {
AppState.Instance.envs.splice(idx, 1);
setEnvs([...AppState.Instance.envs]);
setSelectedEnv(idx);
}
}
@@ -67,10 +98,12 @@ function App() {
<Environment
envs={envs}
onChanged={async (e) => await handleEnvChanged(e)}
onSelected={x => setSelectedEnv(x)} />
onSelected={handleEnvSelected}
onAdd={handleEnvAdded}
onRemove={handleEnvRemoved} />
</section>
<section id="content" className="col-8 col-xl-7 border-start ms-1">
<Content env={envs[selectedEnv]} config={config} />
<Content env={currentEnv} config={config} />
</section>
</div>)
:

View File

@@ -129,7 +129,7 @@ function fillTemplate(config: Config, env: Env): string {
let filledTemplate = config.template.content;
const placeholderRegex = /@(\w+)@/g;
filledTemplate = filledTemplate.replace(placeholderRegex, (match, paramName) => {
filledTemplate = filledTemplate.replace(placeholderRegex, (_, paramName) => {
if (paramName === Config.ENV_NAME_PARAM) {
return env.name ?? "--NO-VALUE--";
}

View File

@@ -1,10 +1,19 @@
import { useState } from "react";
import { useState, useEffect } 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]);
export function Environment(props: { envs: Env[], onChanged: (env: Env) => void, onSelected: (envId: number) => void, onAdd: (env: Env) => number, onRemove: (envId: number) => void }) {
const [currEnvId, setCurrEnvId] = useState(props.envs[0]?.id);
// Sync currEnvId when props.envs changes
useEffect(() => {
if (!props.envs.find(e => e.id === currEnvId)) {
setCurrEnvId(props.envs[0]?.id);
}
}, [props.envs, currEnvId]);
const currEnv = props.envs.find(e => e.id === currEnvId) ?? props.envs[0];
function handleParamChanged(e: AppEvent<EnvParam>) {
let isChanged = false;
@@ -26,12 +35,43 @@ export function Environment(props: { envs: Env[], onChanged: (env: Env) => void,
}
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);
props.onChanged(env);
setCurrEnvId(env.id);
}
}
function handleAddEnv() {
const name = prompt("Enter new environment name:");
if (!name || name.trim() === "") return;
const newEnv = new Env(
Math.random() * 10000,
name.trim(),
[...currEnv.params]
);
// Parent synchronously adds the env and returns the index
const newIdx = props.onAdd(newEnv);
setCurrEnvId(newEnv.id);
props.onSelected(newIdx);
}
function handleRemoveEnv() {
if (currEnv.isDefault()) {
alert("Cannot remove DEFAULT environment");
return;
}
if (!confirm(`Remove environment "${currEnv.name}"?`)) return;
const idx = props.envs.findIndex(x => x.id === currEnv.id);
if (idx > -1 && currEnv.id !== undefined) {
// Let parent handle the removal
props.onRemove(currEnv.id);
const newIdx = Math.max(0, idx - 1);
const newEnv = props.envs[newIdx];
if (newEnv?.id !== undefined) {
setCurrEnvId(newEnv.id);
}
props.onSelected(newIdx);
}
}
@@ -44,19 +84,28 @@ export function Environment(props: { envs: Env[], onChanged: (env: Env) => void,
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 className="row g-0">
<div className="col">
<select
id="environments"
name="environments"
aria-label="Environments"
className="form-select"
value={currEnvId}
onChange={x => {
let id = Number.parseInt(x.target.value);
setCurrEnvId(id);
props.onSelected(id);
}}>
{selectOptions}
</select>
</div>
<div className="col-auto ms-2">
<button className="btn btn-success" onClick={handleAddEnv} title="Add environment"></button>
</div>
<div className="col-auto ms-2">
<button className="btn btn-danger" onClick={handleRemoveEnv} title="Remove environment" disabled={currEnv.isDefault()}></button>
</div>
</div>
<div className="row">Params</div>
{paramCtrls}

View File

@@ -0,0 +1,111 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Env } from '../models/Env';
import { EnvParam } from '../models/EnvParam';
describe('Environment Management', () => {
let envs: Env[];
beforeEach(() => {
envs = [
new Env(0, 'DEFAULT', [
new EnvParam(1, 'host', 'http://localhost'),
new EnvParam(2, 'port', '8080')
]),
new Env(1, 'env1', [
new EnvParam(3, 'port', '9090')
])
];
});
describe('add environment', () => {
it('should create new environment with copied params from current', () => {
const currentEnv = envs[1]; // env1 with port=9090
const newEnvName = 'env2';
// Simulate copying params from current env
const newEnv = new Env(
Math.random() * 10000,
newEnvName,
[...currentEnv.params]
);
envs.push(newEnv);
expect(envs).toHaveLength(3);
expect(envs[2].name).toBe('env2');
expect(envs[2].params).toHaveLength(1);
expect(envs[2].params[0].name).toBe('port');
expect(envs[2].params[0].value).toBe('9090');
});
it('should create new environment with empty params from DEFAULT', () => {
const currentEnv = envs[0]; // DEFAULT
const newEnv = new Env(
Math.random() * 10000,
'newEnv',
[...currentEnv.params]
);
envs.push(newEnv);
expect(envs).toHaveLength(3);
expect(newEnv.params).toHaveLength(2);
expect(newEnv.params.map(p => p.name)).toContain('host');
expect(newEnv.params.map(p => p.name)).toContain('port');
});
});
describe('remove environment', () => {
it('should not allow removing DEFAULT environment', () => {
const defaultEnv = envs.find(e => e.name === 'DEFAULT');
expect(defaultEnv?.isDefault()).toBe(true);
// In the component, this is blocked by check: if (currEnv.isDefault())
// Simulating the protection
const canRemove = !defaultEnv?.isDefault();
expect(canRemove).toBe(false);
});
it('should remove non-DEFAULT environment', () => {
const envToRemove = envs[1]; // env1
const idx = envs.findIndex(x => x.id === envToRemove.id);
expect(idx).toBe(1);
expect(envs).toHaveLength(2);
// Simulate removal
envs.splice(idx, 1);
expect(envs).toHaveLength(1);
expect(envs.find(e => e.id === envToRemove.id)).toBeUndefined();
});
it('should select previous environment after removal', () => {
const idxToRemove = 1; // removing env1
envs.splice(idxToRemove, 1);
// After removal, should select max(0, idx-1) = max(0, 0) = 0
const newSelectedIdx = Math.max(0, idxToRemove - 1);
expect(newSelectedIdx).toBe(0);
expect(envs[newSelectedIdx].name).toBe('DEFAULT');
});
});
describe('environment selection', () => {
it('should find current env by ID from props', () => {
const selectedId = 1;
const currEnv = envs.find(e => e.id === selectedId) ?? envs[0];
expect(currEnv).toBeDefined();
expect(currEnv.id).toBe(1);
expect(currEnv.name).toBe('env1');
});
it('should fallback to first env if ID not found', () => {
const selectedId = 999; // non-existent
const currEnv = envs.find(e => e.id === selectedId) ?? envs[0];
expect(currEnv).toBeDefined();
expect(currEnv.id).toBe(0);
expect(currEnv.name).toBe('DEFAULT');
});
});
});