diff --git a/.gitignore b/.gitignore index a547bf3..d63a1ca 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ dist-ssr *.njsproj *.sln *.sw? +playwright-report \ No newline at end of file diff --git a/e2e/environment.spec.ts b/e2e/environment.spec.ts new file mode 100644 index 0000000..adc783e --- /dev/null +++ b/e2e/environment.spec.ts @@ -0,0 +1,140 @@ +import { test, expect } from '@playwright/test'; +import * as fs from 'fs'; + +test.describe('Environment Management', () => { + test('should not allow removing DEFAULT environment', async ({ page }) => { + await page.goto('/'); + await page.click('button:has-text("Create new")'); + const removeButton = page.locator('button.btn-danger[title="Remove environment"]'); + await expect(removeButton).toBeDisabled(); + }); + + test('should remove non-DEFAULT environment', async ({ page }) => { + await page.goto('/'); + await page.click('button:has-text("Create new")'); + page.once('dialog', async dialog => { await dialog.accept('toRemove'); }); + await page.click('button.btn-success[title="Add environment"]'); + await page.waitForTimeout(500); + await expect(page.locator('#environments option')).toHaveCount(2); + page.once('dialog', async dialog => { await dialog.accept(); }); + await page.click('button.btn-danger[title="Remove environment"]'); + await page.waitForTimeout(300); + await expect(page.locator('#environments option')).toHaveCount(1); + }); + + test('should create new environment and switch without errors', async ({ page }) => { + await page.goto('/'); + await page.click('button:has-text("Create new")'); + page.once('dialog', async dialog => { await dialog.accept('env1'); }); + await page.click('button.btn-success[title="Add environment"]'); + await page.waitForTimeout(500); + await expect(page.locator('#environments option')).toHaveCount(2); + await page.locator('#environments').selectOption({ index: 0 }); + await page.waitForTimeout(300); + await page.locator('#environments').selectOption('env1'); + await page.waitForTimeout(300); + await expect(page.locator('#environments')).toBeVisible(); + }); + + test('should create multiple environments and switch between them', async ({ page }) => { + await page.goto('/'); + await page.click('button:has-text("Create new")'); + page.once('dialog', async dialog => { await dialog.accept('env1'); }); + await page.click('button.btn-success[title="Add environment"]'); + await page.waitForTimeout(500); + page.once('dialog', async dialog => { await dialog.accept('env2'); }); + await page.click('button.btn-success[title="Add environment"]'); + await page.waitForTimeout(500); + await expect(page.locator('#environments option')).toHaveCount(3); + await page.locator('#environments').selectOption({ index: 0 }); + await page.waitForTimeout(200); + await page.locator('#environments').selectOption('env1'); + await page.waitForTimeout(200); + await page.locator('#environments').selectOption('env2'); + await page.waitForTimeout(200); + await expect(page.locator('#environments')).toBeVisible(); + }); + + test('should add params and edit template manually', async ({ page }) => { + await page.goto('/'); + await page.click('button:has-text("Create new")'); + const nameInput = page.locator('input[placeholder="name"]').first(); + const valueInput = page.locator('input[placeholder="value"]').first(); + const addButton = page.locator('button.btn-success').first(); + await nameInput.fill('host'); + await valueInput.fill('localhost:8080'); + await addButton.click(); + await page.waitForTimeout(500); + await nameInput.fill('port'); + await valueInput.fill('9090'); + await addButton.click(); + await page.waitForTimeout(500); + await page.click('a:has-text("Content Template")'); + await page.waitForTimeout(500); + await expect(page.locator('button:has-text("Edit")')).toBeVisible(); + await page.click('button:has-text("Edit")'); + await page.waitForTimeout(500); + const textarea = page.locator('textarea'); + await expect(textarea).toBeVisible(); + await textarea.fill('{\n "!!! host": "@host@",\n "!!! port": "@port@",\n "!!! custom": "@custom@"\n}'); + await page.waitForTimeout(300); + await page.click('button:has-text("Save")'); + await page.waitForTimeout(500); + await expect(page.locator('button:has-text("Edit")')).toBeVisible(); + const pageContent = await page.content(); + expect(pageContent).toContain('!!! custom'); + }); + + test('should not duplicate params when placeholder already exists', async ({ page }) => { + await page.goto('/'); + await page.click('button:has-text("Create new")'); + const nameInput = page.locator('input[placeholder="name"]').first(); + const valueInput = page.locator('input[placeholder="value"]').first(); + const addButton = page.locator('button.btn-success').first(); + await nameInput.fill('host'); + await valueInput.fill('localhost:8080'); + await addButton.click(); + await page.waitForTimeout(500); + await page.click('a:has-text("Content Template")'); + await page.waitForTimeout(500); + await page.click('button:has-text("Edit")'); + await page.waitForTimeout(300); + const textarea = page.locator('textarea'); + await textarea.fill('{\n "!!! host": "@host@",\n "apiUrl": "http://@host@/api"\n}'); + await page.waitForTimeout(300); + await page.click('button:has-text("Save")'); + await page.waitForTimeout(500); + await page.click('a:has-text("Env")'); + await page.waitForTimeout(300); + await nameInput.fill('host'); + await valueInput.fill('updated-host:9090'); + await addButton.click(); + await page.waitForTimeout(500); + await page.click('a:has-text("Content Template")'); + await page.waitForTimeout(500); + const templateContent = await page.locator('.config-template-editor').textContent(); + const hostKeyCount = (templateContent.match(/!!! host/g) || []).length; + expect(hostKeyCount).toBe(1); + const hostPlaceholderCount = (templateContent.match(/@host@/g) || []).length; + expect(hostPlaceholderCount).toBe(2); + }); + + test('should download config file with correct filename', async ({ page }) => { + await page.goto('/'); + await page.click('button:has-text("Create new")'); + await page.waitForTimeout(500); + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.click('button:has-text("Download")') + ]); + const filename = download.suggestedFilename(); + expect(filename).toMatch(/^config_\d{2}-\d{2}-\d{2}-\d{4}\.json\.xml$/); + // Save and read the file to verify content + await download.saveAs('temp-config.xml'); + const contentStr = fs.readFileSync('temp-config.xml', 'utf8'); + expect(contentStr).toContain('engine'); + expect(contentStr).toContain('DEFAULT'); + expect(contentStr).toContain('template'); + fs.unlinkSync('temp-config.xml'); + }); +}); 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.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..d9009c1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,44 +11,92 @@ 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 { - - // Create a promise that resolves after 1 second + // Simulate async save with 1 second delay return await new Promise((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 + console.log("Saved env:", env.name); + resolve(0); }, 1000); }); } } function App() { - const [envs, setEnvs] = useState(AppState.Instance.envs); + const [config, setConfig] = useState(() => AppState.Instance.config); + const [envs, setEnvs] = useState(() => AppState.Instance.config.envs); 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); - if (idx > -1) { - setEnvs([...AppState.Instance.envs]); - setSelectedEnv(idx); - } + // Optimistic update - update React state immediately + setEnvs(prevEnvs => { + const newEnvs = [...prevEnvs]; + const idx = newEnvs.findIndex(x => x.id === env.id); + if (idx > -1) { + newEnvs[idx] = env; + } + return newEnvs; + }); + + // Also update config.envs and template to keep them in sync + setConfig(prevConfig => { + const newConfig = new Config(); + newConfig.envs = prevConfig.envs.map(e => e.id === env.id ? env : e); + // Update template JSON with params from this environment + newConfig.template = prevConfig.template; + newConfig.updateTemplateFromEnv(env); + return newConfig; + }); + + // Fire off async save in background (no need to wait) + AppState.Instance.saveEnv(env); + } + + function handleEnvSelected(idx: number) { + setSelectedEnv(idx); + } + + function handleEnvAdded(env: Env): number { + const newIdx = envs.length; + setEnvs(prevEnvs => [...prevEnvs, env]); + setConfig(prevConfig => { + const newConfig = new Config(); + newConfig.envs = [...prevConfig.envs, env]; + newConfig.template = prevConfig.template; + return newConfig; + }); + return newIdx; + } + + function handleEnvRemoved(envId: number) { + setEnvs(prevEnvs => prevEnvs.filter(e => e.id !== envId)); + setConfig(prevConfig => { + const newConfig = new Config(); + newConfig.envs = prevConfig.envs.filter(e => e.id !== envId); + newConfig.template = prevConfig.template; + return newConfig; + }); + } + + function handleTemplateSaved(newContent: string) { + setConfig(prevConfig => { + const newConfig = new Config(); + newConfig.envs = prevConfig.envs; + newConfig.addTemplate(newContent); + return newConfig; + }); } return ( @@ -57,9 +105,9 @@ function App() {
{ AppState.Instance.loadConfig(x); - setEnvs(AppState.Instance.envs); - setConfig(AppState.Instance.config); - }} /> + setEnvs(x.envs); + setConfig(x); + }} config={config} />
{envs.length > 0 ? (
@@ -67,10 +115,12 @@ function App() { await handleEnvChanged(e)} - onSelected={x => setSelectedEnv(x)} /> + onSelected={handleEnvSelected} + onAdd={handleEnvAdded} + onRemove={handleEnvRemoved} />
- +
) : diff --git a/src/builders/ConfigBuilder.ts b/src/builders/ConfigBuilder.ts new file mode 100644 index 0000000..677190a --- /dev/null +++ b/src/builders/ConfigBuilder.ts @@ -0,0 +1,39 @@ +import { Config } from "../models/Config"; +import { EnvBuilder } from "./EnvBuilder"; + +export class ConfigBuilder { + /** + * Builds a full XML config file with all environments and template. + */ + public static buildFullXml(config: Config): string { + const lines: string[] = []; + lines.push(""); + + // Add all environments + for (const env of config.envs) { + const envXml = new EnvBuilder().buildEnv(env); + lines.push(envXml); + } + + // Add template + lines.push(" "); + + lines.push(""); + return lines.join("\r\n"); + } + + /** + * Generates filename with timestamp: config_yy-dd-MM-HHmm.json.xml + */ + public static generateFilename(): string { + const now = new Date(); + const yy = now.getFullYear().toString().slice(-2); + const dd = String(now.getDate()).padStart(2, '0'); + const MM = String(now.getMonth() + 1).padStart(2, '0'); + const HH = String(now.getHours()).padStart(2, '0'); + const mm = String(now.getMinutes()).padStart(2, '0'); + return `config_${yy}-${dd}-${MM}-${HH}${mm}.json.xml`; + } +} diff --git a/src/builders/EnvBuilder.ts b/src/builders/EnvBuilder.ts index 2682d0e..3bb1efe 100644 --- a/src/builders/EnvBuilder.ts +++ b/src/builders/EnvBuilder.ts @@ -16,6 +16,14 @@ export class EnvBuilder implements IBuilder { this._src = v; } + /** + * Builds XML for a single environment (static method for direct use) + */ + public buildEnv(env: Env): string { + this.src = env; + return this.build(); + } + build(): string { return this .open() diff --git a/src/builders/index.ts b/src/builders/index.ts index 054b805..166618d 100644 --- a/src/builders/index.ts +++ b/src/builders/index.ts @@ -1,9 +1,6 @@ import { Env } from "../models/Env"; import { EnvBuilder } from "./EnvBuilder"; - - - - +import { ConfigBuilder } from "./ConfigBuilder"; export interface IBuilder { get src(): T; @@ -20,7 +17,9 @@ export class Builder { }; public static getEnvs(envs: Env[]): string { - return envs.map(x => Builder.getEnv(x).build()).join("\r\n"); + return envs.map(x => Builder.getEnv(x).build()).join("\r\n"); } } +export { ConfigBuilder }; + diff --git a/src/componets/FileChooser.tsx b/src/componets/FileChooser.tsx index 616cdf6..b86ca7d 100644 --- a/src/componets/FileChooser.tsx +++ b/src/componets/FileChooser.tsx @@ -1,9 +1,9 @@ import { Env } from "../models/Env"; import { ConfigReader } from "../models/ConfigReader"; import { Config } from "../models/Config"; +import { ConfigBuilder } from "../builders/ConfigBuilder"; - -export function FileChooser(props: { onSelected: (x: Config) => void }) { +export function FileChooser(props: { onSelected: (x: Config) => void, config?: Config }) { async function handleFile(x: React.ChangeEvent) { let file = x.target.files![0]; @@ -18,15 +18,47 @@ export function FileChooser(props: { onSelected: (x: Config) => void }) { function handleNew(){ let cfg = new Config(); - cfg.addEnvs([new Env(0,"DEFAULT", [])]); + cfg.addEnvs([new Env(0, "DEFAULT", [])]); + cfg.addTemplate("{}"); props.onSelected(cfg); } + function handleDownload() { + if (!props.config) { + alert("No configuration loaded"); + return; + } + + const xmlContent = ConfigBuilder.buildFullXml(props.config); + const filename = ConfigBuilder.generateFilename(); + + // Create blob and download + const blob = new Blob([xmlContent], { type: "text/xml" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } + return ( <>
+
+ +
or
diff --git a/src/componets/content/ConfigTemplate.tsx b/src/componets/content/ConfigTemplate.tsx new file mode 100644 index 0000000..28464fb --- /dev/null +++ b/src/componets/content/ConfigTemplate.tsx @@ -0,0 +1,142 @@ +import { useState, useEffect } from "react"; +import Highlight from 'react-highlight'; +import 'highlight.js/styles/far.css'; +import { Config } from "../../models/Config"; + +interface ConfigTemplateProps { + config: Config; + onSaved: (newContent: string) => void; +} + +export function ConfigTemplate(props: ConfigTemplateProps) { + const [mode, setMode] = useState<'view' | 'edit'>('view'); + const [draftContent, setDraftContent] = useState(props.config.template.content); + const [originalContent, setOriginalContent] = useState(props.config.template.content); + const [jsonError, setJsonError] = useState(null); + + // Sync draft when config changes (only in view mode) + useEffect(() => { + if (mode === 'view') { + setDraftContent(props.config.template.content); + } + }, [props.config.template.content, mode]); + + function handleEdit() { + setOriginalContent(props.config.template.content); + setDraftContent(props.config.template.content); + setJsonError(null); + setMode('edit'); + } + + function handleRevert() { + setDraftContent(originalContent); + setJsonError(null); + setMode('view'); + } + + function handleSave() { + // Validate JSON before saving + try { + JSON.parse(draftContent); + setJsonError(null); + props.onSaved(draftContent); + setMode('view'); + } catch (e) { + setJsonError((e as Error).message); + } + } + + function handleDraftChange(value: string) { + setDraftContent(value); + // Validate JSON on every change + try { + if (value.trim()) { + // Replace @placeholders@ with valid JSON values for validation + const sanitizedValue = value.replace(/"@?(\w+)@?"/g, '"__PLACEHOLDER__"'); + JSON.parse(sanitizedValue); + setJsonError(null); + } else { + setJsonError(null); + } + } catch (e) { + setJsonError((e as Error).message); + } + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Tab') { + e.preventDefault(); + const textarea = e.currentTarget; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const value = textarea.value; + + // Insert 2 spaces at cursor position + const newValue = value.substring(0, start) + ' ' + value.substring(end); + handleDraftChange(newValue); + + // Move cursor after the inserted spaces + setTimeout(() => { + textarea.selectionStart = textarea.selectionEnd = start + 2; + }, 0); + } + } + + const isValidJson = jsonError === null; + + return ( +
+ {mode === 'view' ? ( + <> +
+ +
+ + {props.config.template.content || "{}"} + + + ) : ( + <> +
+ + + + {isValidJson ? 'Valid JSON' : 'Invalid JSON'} + +
+ {jsonError && ( +
+ {jsonError} +
+ )} +