diff --git a/e2e/environment.spec.ts b/e2e/environment.spec.ts index 5b36a80..adc783e 100644 --- a/e2e/environment.spec.ts +++ b/e2e/environment.spec.ts @@ -1,106 +1,55 @@ 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")'); - - // 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'); - }); + 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 - }); + page.once('dialog', async dialog => { await dialog.accept(); }); 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'); - }); + 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); }); test('should create multiple environments and switch between them', async ({ page }) => { await page.goto('/'); await page.click('button:has-text("Create new")'); - - // Create env1 - page.once('dialog', async dialog => { - await dialog.accept('env1'); - }); + page.once('dialog', async dialog => { await dialog.accept('env1'); }); await page.click('button.btn-success[title="Add environment"]'); await page.waitForTimeout(500); - - // Create env2 - page.once('dialog', async dialog => { - await dialog.accept('env2'); - }); + page.once('dialog', async dialog => { await dialog.accept('env2'); }); await page.click('button.btn-success[title="Add environment"]'); await page.waitForTimeout(500); - - // Verify we have 3 envs (DEFAULT + env1 + env2) await expect(page.locator('#environments option')).toHaveCount(3); - - // Switch to each env and verify page doesn't crash await page.locator('#environments').selectOption({ index: 0 }); await page.waitForTimeout(200); - await expect(page.locator('#environments')).toBeVisible(); - await page.locator('#environments').selectOption('env1'); await page.waitForTimeout(200); - await expect(page.locator('#environments')).toBeVisible(); - await page.locator('#environments').selectOption('env2'); await page.waitForTimeout(200); await expect(page.locator('#environments')).toBeVisible(); @@ -109,105 +58,83 @@ test.describe('Environment Management', () => { test('should add params and edit template manually', async ({ page }) => { await page.goto('/'); await page.click('button:has-text("Create new")'); - - // Step 1: Add a param to DEFAULT 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); - - // Add second param await nameInput.fill('port'); await valueInput.fill('9090'); await addButton.click(); await page.waitForTimeout(500); - - // Step 2: Switch to Content Template tab await page.click('a:has-text("Content Template")'); await page.waitForTimeout(500); - - // Verify the tab content is visible (check for Edit button) await expect(page.locator('button:has-text("Edit")')).toBeVisible(); - - // Step 3: Click Edit button await page.click('button:has-text("Edit")'); await page.waitForTimeout(500); - - // Step 4: Verify textarea is visible const textarea = page.locator('textarea'); await expect(textarea).toBeVisible(); - - // Step 5: Edit the template manually - add a new key await textarea.fill('{\n "!!! host": "@host@",\n "!!! port": "@port@",\n "!!! custom": "@custom@"\n}'); await page.waitForTimeout(300); - - // Step 6: Click Save await page.click('button:has-text("Save")'); await page.waitForTimeout(500); - - // Step 7: Verify the template was saved (Edit button should be visible again) await expect(page.locator('button:has-text("Edit")')).toBeVisible(); - - // Verify the content contains the new key const pageContent = await page.content(); expect(pageContent).toContain('!!! custom'); }); - test('should not duplicate params when placeholder already exists in template', async ({ page }) => { + test('should not duplicate params when placeholder already exists', async ({ page }) => { await page.goto('/'); await page.click('button:has-text("Create new")'); - - // Step 1: Add a param to DEFAULT 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); - - // Step 2: Switch to Content Template tab await page.click('a:has-text("Content Template")'); await page.waitForTimeout(500); - - // Step 3: Click Edit and manually add @host@ usage in a custom field await page.click('button:has-text("Edit")'); await page.waitForTimeout(300); - const textarea = page.locator('textarea'); - // Add a custom field that uses @host@ placeholder await textarea.fill('{\n "!!! host": "@host@",\n "apiUrl": "http://@host@/api"\n}'); await page.waitForTimeout(300); - - // Step 4: Save await page.click('button:has-text("Save")'); await page.waitForTimeout(500); - - // Step 5: Add ANOTHER param with same name (host) - should not create duplicate 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); - - // Step 6: Switch back to Content Template and verify no duplicate await page.click('a:has-text("Content Template")'); await page.waitForTimeout(500); - - // Count occurrences of "!!! host" - should be exactly 1 const templateContent = await page.locator('.config-template-editor').textContent(); const hostKeyCount = (templateContent.match(/!!! host/g) || []).length; expect(hostKeyCount).toBe(1); - - // The @host@ placeholder should appear twice (once in !!! host, once in apiUrl) 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/src/App.tsx b/src/App.tsx index 4d7ec74..d9009c1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -107,7 +107,7 @@ function App() { AppState.Instance.loadConfig(x); setEnvs(x.envs); setConfig(x); - }} /> + }} config={config} /> {envs.length > 0 ? (
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 9fa74e2..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]; @@ -23,11 +23,42 @@ export function FileChooser(props: { onSelected: (x: Config) => void }) { 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/test/ConfigBuilder.test.ts b/src/test/ConfigBuilder.test.ts new file mode 100644 index 0000000..b55f82a --- /dev/null +++ b/src/test/ConfigBuilder.test.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'vitest'; +import { ConfigBuilder } from '../builders/ConfigBuilder'; + +describe('ConfigBuilder', () => { + describe('generateFilename', () => { + it('should generate filename with correct format', () => { + const filename = ConfigBuilder.generateFilename(); + expect(filename).toMatch(/^config_\d{2}-\d{2}-\d{2}-\d{4}\.json\.xml$/); + }); + }); +});