From 4b8f5f3739eb5d0590577ff08d643399da524d1f Mon Sep 17 00:00:00 2001 From: sokol Date: Wed, 18 Feb 2026 14:04:03 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BE=D0=BA=D1=80=D1=83=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=D0=BC=D0=B8=20=D0=B8=20Playwright=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20E2E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/environment.spec.ts | 73 ++++++++++++++++++++ package-lock.json | 64 +++++++++++++++++ package.json | 4 +- playwright-report/index.html | 85 +++++++++++++++++++++++ playwright.config.ts | 14 ++++ src/App.tsx | 41 +++++++++-- src/componets/content/Content.tsx | 2 +- src/componets/env/Environment.tsx | 91 ++++++++++++++++++------ src/test/Environment.test.tsx | 111 ++++++++++++++++++++++++++++++ test-results/.last-run.json | 4 ++ vitest.config.ts | 3 +- 11 files changed, 464 insertions(+), 28 deletions(-) create mode 100644 e2e/environment.spec.ts create mode 100644 playwright-report/index.html create mode 100644 playwright.config.ts create mode 100644 src/test/Environment.test.tsx create mode 100644 test-results/.last-run.json 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