diff --git a/e2e/environment.spec.ts b/e2e/environment.spec.ts
new file mode 100644
index 0000000..630005d
--- /dev/null
+++ b/e2e/environment.spec.ts
@@ -0,0 +1,73 @@
+import { test, expect } from '@playwright/test';
+
+test.describe('Environment Management', () => {
+ test('should not allow removing DEFAULT environment', async ({ page }) => {
+ await page.goto('/');
+ await page.click('button:has-text("Create new")');
+
+ // Try to remove DEFAULT - should be blocked
+ const removeButton = page.locator('button.btn-danger[title="Remove environment"]');
+
+ // The button should be disabled for DEFAULT
+ await expect(removeButton).toBeDisabled();
+ });
+
+ test('should remove non-DEFAULT environment', async ({ page }) => {
+ await page.goto('/');
+ await page.click('button:has-text("Create new")');
+
+ // Create a new environment
+ page.once('dialog', async dialog => {
+ await dialog.accept('toRemove');
+ });
+ await page.click('button.btn-success[title="Add environment"]');
+ await page.waitForTimeout(500);
+
+ // Verify we have 2 envs
+ await expect(page.locator('#environments option')).toHaveCount(2);
+
+ // Remove the new environment
+ page.once('dialog', async dialog => {
+ await dialog.accept(); // Confirm removal
+ });
+ await page.click('button.btn-danger[title="Remove environment"]');
+ await page.waitForTimeout(300);
+
+ // Verify we're back to 1 env (DEFAULT)
+ await expect(page.locator('#environments option')).toHaveCount(1);
+ await expect(page.locator('#environments')).toContainText('DEFAULT');
+ });
+
+ test('should create new environment and switch without errors', async ({ page }) => {
+ await page.goto('/');
+ await page.click('button:has-text("Create new")');
+
+ // Verify DEFAULT environment is loaded
+ await expect(page.locator('#environments')).toContainText('DEFAULT');
+
+ // Create a new environment
+ page.once('dialog', async dialog => {
+ await dialog.accept('env1');
+ });
+ await page.click('button.btn-success[title="Add environment"]');
+ await page.waitForTimeout(500);
+
+ // Verify new environment is created
+ await expect(page.locator('#environments option')).toHaveCount(2);
+
+ // Switch back to DEFAULT (by index 0)
+ await page.locator('#environments').selectOption({ index: 0 });
+ await page.waitForTimeout(300);
+
+ // Verify the page is still working
+ await expect(page.locator('#environments')).toBeVisible();
+
+ // Switch to env1 (by text) - this should NOT cause error
+ await page.locator('#environments').selectOption('env1');
+ await page.waitForTimeout(300);
+
+ // Verify the page is still working (no white screen of death)
+ await expect(page.locator('#environments')).toBeVisible();
+ await expect(page.locator('#environments option')).toHaveCount(2);
+ });
+});
diff --git a/package-lock.json b/package-lock.json
index ff08922..9ee2cc2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,7 @@
},
"devDependencies": {
"@eslint/js": "^9.17.0",
+ "@playwright/test": "^1.58.2",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-highlight": "^0.12.8",
@@ -1228,6 +1229,22 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
+ "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -3383,6 +3400,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/playwright": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
+ "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
+ "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
diff --git a/package.json b/package.json
index d439896..0fe473b 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,8 @@
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
- "test": "vitest"
+ "test": "vitest",
+ "test:e2e": "playwright test"
},
"dependencies": {
"bootstrap": "^5.3.3",
@@ -18,6 +19,7 @@
},
"devDependencies": {
"@eslint/js": "^9.17.0",
+ "@playwright/test": "^1.58.2",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-highlight": "^0.12.8",
diff --git a/playwright-report/index.html b/playwright-report/index.html
new file mode 100644
index 0000000..d9c09ad
--- /dev/null
+++ b/playwright-report/index.html
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+ Playwright Test Report
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..27a5141
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './e2e',
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? 1 : undefined,
+ reporter: 'html',
+ use: {
+ baseURL: 'http://localhost:5173',
+ trace: 'on-first-retry',
+ },
+});
diff --git a/src/App.tsx b/src/App.tsx
index 1d36842..17a6d07 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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 {
// 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() {
await handleEnvChanged(e)}
- onSelected={x => setSelectedEnv(x)} />
+ onSelected={handleEnvSelected}
+ onAdd={handleEnvAdded}
+ onRemove={handleEnvRemoved} />
)
:
diff --git a/src/componets/content/Content.tsx b/src/componets/content/Content.tsx
index df628e9..8653428 100644
--- a/src/componets/content/Content.tsx
+++ b/src/componets/content/Content.tsx
@@ -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--";
}
diff --git a/src/componets/env/Environment.tsx b/src/componets/env/Environment.tsx
index 3920d7e..9fd69f7 100644
--- a/src/componets/env/Environment.tsx
+++ b/src/componets/env/Environment.tsx
@@ -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) {
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 (
<>
-
-
+
+
+
+
+
+
+
+
+
+
Params
{paramCtrls}
diff --git a/src/test/Environment.test.tsx b/src/test/Environment.test.tsx
new file mode 100644
index 0000000..f9c3ebb
--- /dev/null
+++ b/src/test/Environment.test.tsx
@@ -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');
+ });
+ });
+});
diff --git a/test-results/.last-run.json b/test-results/.last-run.json
new file mode 100644
index 0000000..cbcc1fb
--- /dev/null
+++ b/test-results/.last-run.json
@@ -0,0 +1,4 @@
+{
+ "status": "passed",
+ "failedTests": []
+}
\ No newline at end of file
diff --git a/vitest.config.ts b/vitest.config.ts
index 0ff54e8..d602025 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -7,6 +7,7 @@ export default defineConfig({
plugins: [react()],
test: {
globals: true,
- environment: "jsdom"
+ environment: "jsdom",
+ exclude: ['**/e2e/**', '**/node_modules/**']
}
})
\ No newline at end of file