Compare commits
1 Commits
feat
...
bc796f2aa4
| Author | SHA1 | Date | |
|---|---|---|---|
| bc796f2aa4 |
@@ -1,99 +0,0 @@
|
|||||||
# Gitea Actions Workflows
|
|
||||||
|
|
||||||
## Workflows
|
|
||||||
|
|
||||||
### 1. CI (`ci.yml`)
|
|
||||||
**Triggers:** Push/PR to `main`, `rewrite`, `feat` branches
|
|
||||||
|
|
||||||
**Jobs:**
|
|
||||||
- Install dependencies
|
|
||||||
- Lint code
|
|
||||||
- Build application
|
|
||||||
- Run unit tests (Vitest)
|
|
||||||
- Run E2E tests (Playwright)
|
|
||||||
- Upload test results as artifacts
|
|
||||||
|
|
||||||
### 2. Docker Build (`docker-build.yml`)
|
|
||||||
**Triggers:**
|
|
||||||
- Tags matching `v*` (e.g., `v1.0.0`)
|
|
||||||
- Push to `main` branch
|
|
||||||
|
|
||||||
**Jobs:**
|
|
||||||
- Build React application
|
|
||||||
- Build Docker image locally
|
|
||||||
- Save image as tarball artifact
|
|
||||||
- Upload artifact for download (30 days retention)
|
|
||||||
|
|
||||||
**Output:**
|
|
||||||
- Docker image artifact: `configucci.tar`
|
|
||||||
- Can be downloaded and loaded with: `docker load -i configucci.tar`
|
|
||||||
|
|
||||||
### 3. Deploy (`deploy.yml`)
|
|
||||||
**Triggers:** Push to `main` branch
|
|
||||||
|
|
||||||
**Jobs:**
|
|
||||||
- Build React application
|
|
||||||
- Build Docker image locally
|
|
||||||
- Create docker-compose.yml configuration
|
|
||||||
- Deploy container on Gitea runner (port 11088)
|
|
||||||
- Health check to verify application is running
|
|
||||||
- Cleanup old Docker images
|
|
||||||
|
|
||||||
**No SSH required** - Everything runs natively on the Gitea Actions runner!
|
|
||||||
|
|
||||||
**Output:**
|
|
||||||
- Application available at: `http://<gitea-server>:11088`
|
|
||||||
- Container auto-restarts on failure
|
|
||||||
- Health check ensures successful deployment
|
|
||||||
|
|
||||||
## Setup Instructions
|
|
||||||
|
|
||||||
### 1. Enable Gitea Actions
|
|
||||||
Make sure Actions is enabled in your Gitea instance:
|
|
||||||
```ini
|
|
||||||
[actions]
|
|
||||||
ENABLED = true
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Configure Runner
|
|
||||||
Ensure your Gitea runner has Docker and docker-compose installed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install Docker
|
|
||||||
curl -fsSL https://get.docker.com | sh
|
|
||||||
|
|
||||||
# Install docker-compose
|
|
||||||
sudo apt-get install docker-compose-plugin
|
|
||||||
```
|
|
||||||
|
|
||||||
**No secrets required** - Everything runs on the runner!
|
|
||||||
|
|
||||||
## Workflow Files Location
|
|
||||||
`.gitea/workflows/`
|
|
||||||
|
|
||||||
## Testing Workflows Locally
|
|
||||||
|
|
||||||
You can test workflows locally using [act](https://github.com/nektos/act):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install act
|
|
||||||
brew install act
|
|
||||||
|
|
||||||
# Run CI workflow locally
|
|
||||||
act push
|
|
||||||
|
|
||||||
# Run with specific job
|
|
||||||
act -j build-and-test
|
|
||||||
|
|
||||||
# Run with verbose output
|
|
||||||
act -v
|
|
||||||
```
|
|
||||||
|
|
||||||
## Badge Examples
|
|
||||||
|
|
||||||
Add these to your README.md:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
[](https://git.six83.ru/ssa/configucci/actions/workflows/ci.yml)
|
|
||||||
[](https://git.six83.ru/ssa/configucci/actions/workflows/deploy.yml)
|
|
||||||
```
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, rewrite, feat]
|
|
||||||
pull_request:
|
|
||||||
branches: [main, rewrite, feat]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: npm run lint
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
run: npm run test
|
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
|
||||||
run: npx playwright install --with-deps chromium
|
|
||||||
|
|
||||||
- name: Start dev server
|
|
||||||
run: npm run dev &
|
|
||||||
env:
|
|
||||||
HOST: 0.0.0.0
|
|
||||||
PORT: 5173
|
|
||||||
|
|
||||||
- name: Wait for dev server
|
|
||||||
run: |
|
|
||||||
echo "Waiting for dev server to start..."
|
|
||||||
for i in {1..30}; do
|
|
||||||
if curl -s http://localhost:5173 > /dev/null 2>&1; then
|
|
||||||
echo "Dev server is ready!"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
echo "Dev server failed to start"
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Run E2E tests
|
|
||||||
run: npm run test:e2e
|
|
||||||
|
|
||||||
- name: Upload test results
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: test-results
|
|
||||||
path: test-results/
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
name: Deploy to Server
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Build application
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Build Docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: false
|
|
||||||
load: true
|
|
||||||
tags: configucci:latest
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
- name: Create docker-compose.yml
|
|
||||||
run: |
|
|
||||||
cat > docker-compose.yml << 'EOF'
|
|
||||||
version: '3.8'
|
|
||||||
services:
|
|
||||||
configucci:
|
|
||||||
image: configucci:latest
|
|
||||||
container_name: configucci
|
|
||||||
ports:
|
|
||||||
- "11088:80"
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:80"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
EOF
|
|
||||||
|
|
||||||
- name: Stop existing containers
|
|
||||||
run: docker-compose down || true
|
|
||||||
|
|
||||||
- name: Start new container
|
|
||||||
run: docker-compose up -d
|
|
||||||
|
|
||||||
- name: Wait for application health
|
|
||||||
run: |
|
|
||||||
echo "Waiting for application to be healthy..."
|
|
||||||
for i in {1..30}; do
|
|
||||||
if curl -s http://localhost:11088 > /dev/null 2>&1; then
|
|
||||||
echo "Application is ready!"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
echo "Application failed to start"
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Cleanup old images
|
|
||||||
run: docker system prune -f
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
name: Build Docker Image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Build application
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Build Docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: false
|
|
||||||
load: true
|
|
||||||
tags: configucci:latest
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
|
|
||||||
- name: Save Docker image as tarball
|
|
||||||
run: docker save configucci:latest -o configucci.tar
|
|
||||||
|
|
||||||
- name: Upload Docker image artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: docker-image
|
|
||||||
path: configucci.tar
|
|
||||||
retention-days: 30
|
|
||||||
@@ -179,19 +179,6 @@ test.describe('Environment Management', () => {
|
|||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.click('button:has-text("New Config")');
|
await page.click('button:has-text("New Config")');
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Add a parameter to make the config non-empty (Download button requires non-empty config)
|
|
||||||
await page.click('button[title="Add parameter"]');
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
const nameInput = page.locator('input[placeholder="Parameter name"]').last();
|
|
||||||
const valueInput = page.locator('input[placeholder="Parameter value"]').last();
|
|
||||||
const addButton = page.locator('button[title="Add parameter"]').last();
|
|
||||||
await nameInput.fill('host');
|
|
||||||
await valueInput.fill('localhost');
|
|
||||||
await addButton.click();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
// Now download should work
|
|
||||||
const [download] = await Promise.all([
|
const [download] = await Promise.all([
|
||||||
page.waitForEvent('download'),
|
page.waitForEvent('download'),
|
||||||
page.click('button:has-text("Download")')
|
page.click('button:has-text("Download")')
|
||||||
|
|||||||
@@ -3,31 +3,21 @@ import globals from 'globals'
|
|||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
export default tseslint.config(
|
export default defineConfig([
|
||||||
{
|
globalIgnores(['dist']),
|
||||||
ignores: ['dist'],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
tseslint.configs.recommended,
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
],
|
],
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
plugins: {
|
|
||||||
'react-hooks': reactHooks,
|
|
||||||
'react-refresh': reactRefresh,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
...reactHooks.configs.recommended.rules,
|
|
||||||
'react-refresh/only-export-components': [
|
|
||||||
'warn',
|
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
])
|
||||||
|
|||||||
14
src/App.tsx
14
src/App.tsx
@@ -98,7 +98,7 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
|
||||||
<main className="container mx-auto px-4 py-6 max-w-[1920px] min-h-[calc(100vh-48px)]">
|
<main className="container mx-auto px-4 py-6 max-w-7xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<FileChooser
|
<FileChooser
|
||||||
@@ -112,10 +112,10 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{envs.length > 0 ? (
|
{envs.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 min-h-[calc(100vh-200px)]">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||||
{/* Environment Panel - 5/12 width */}
|
{/* Environment Panel */}
|
||||||
<section className="lg:col-span-5 xl:col-span-5 2xl:col-span-5">
|
<section className="lg:col-span-5 xl:col-span-4">
|
||||||
<div className="sticky top-6 h-full">
|
<div className="sticky top-6">
|
||||||
<Environment
|
<Environment
|
||||||
envs={envs}
|
envs={envs}
|
||||||
onChanged={async (e) => await handleEnvChanged(e)}
|
onChanged={async (e) => await handleEnvChanged(e)}
|
||||||
@@ -126,8 +126,8 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Content Panel - 7/12 width */}
|
{/* Content Panel */}
|
||||||
<section className="lg:col-span-7 xl:col-span-7 2xl:col-span-7 h-full">
|
<section className="lg:col-span-7 xl:col-span-8">
|
||||||
<Content
|
<Content
|
||||||
env={currentEnv}
|
env={currentEnv}
|
||||||
config={config}
|
config={config}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export class EnvBuilder implements IBuilder<Env> {
|
|||||||
|
|
||||||
private params(): this {
|
private params(): this {
|
||||||
const tag = `<parameter name="{name}" value="{val}" />`;
|
const tag = `<parameter name="{name}" value="{val}" />`;
|
||||||
for (const p of this.src.params) {
|
for (let p of this.src.params) {
|
||||||
this.stack.push(this.ident);
|
this.stack.push(this.ident);
|
||||||
this.stack.push(tag
|
this.stack.push(tag
|
||||||
.replace("{name}", p.name ?? "!ERR!")
|
.replace("{name}", p.name ?? "!ERR!")
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export interface IBuilder<T> {
|
|||||||
|
|
||||||
export class Builder {
|
export class Builder {
|
||||||
public static getEnv(env: Env): IBuilder<Env> {
|
public static getEnv(env: Env): IBuilder<Env> {
|
||||||
const b = new EnvBuilder();
|
let b = new EnvBuilder();
|
||||||
b.src = env;
|
b.src = env;
|
||||||
return b;
|
return b;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
|
iconPosition?: 'left' | 'right';
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
variant = 'primary',
|
variant = 'primary',
|
||||||
size = 'md',
|
size = 'md',
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
|
iconPosition = 'left',
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
disabled,
|
disabled,
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(
|
|||||||
|
|
||||||
Card.displayName = 'Card';
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
type CardHeaderProps = HTMLAttributes<HTMLDivElement>;
|
interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {}
|
||||||
|
|
||||||
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
|
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
|
||||||
({ className = '', children, ...props }, ref) => {
|
({ className = '', children, ...props }, ref) => {
|
||||||
@@ -52,7 +52,7 @@ export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
|
|||||||
|
|
||||||
CardHeader.displayName = 'CardHeader';
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
type CardBodyProps = HTMLAttributes<HTMLDivElement>;
|
interface CardBodyProps extends HTMLAttributes<HTMLDivElement> {}
|
||||||
|
|
||||||
export const CardBody = forwardRef<HTMLDivElement, CardBodyProps>(
|
export const CardBody = forwardRef<HTMLDivElement, CardBodyProps>(
|
||||||
({ className = '', children, ...props }, ref) => {
|
({ className = '', children, ...props }, ref) => {
|
||||||
@@ -66,7 +66,7 @@ export const CardBody = forwardRef<HTMLDivElement, CardBodyProps>(
|
|||||||
|
|
||||||
CardBody.displayName = 'CardBody';
|
CardBody.displayName = 'CardBody';
|
||||||
|
|
||||||
type CardFooterProps = HTMLAttributes<HTMLDivElement>;
|
interface CardFooterProps extends HTMLAttributes<HTMLDivElement> {}
|
||||||
|
|
||||||
export const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(
|
export const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(
|
||||||
({ className = '', children, ...props }, ref) => {
|
({ className = '', children, ...props }, ref) => {
|
||||||
|
|||||||
@@ -60,8 +60,6 @@ export function FileChooser({ onSelected, config }: FileChooserProps) {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasConfig = !!config && !config.isEmpty();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl shadow-md p-4 border border-slate-200">
|
<div className="bg-white rounded-xl shadow-md p-4 border border-slate-200">
|
||||||
<div className="flex items-center gap-4 flex-wrap">
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
@@ -91,8 +89,8 @@ export function FileChooser({ onSelected, config }: FileChooserProps) {
|
|||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
icon={Download}
|
icon={Download}
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!hasConfig}
|
disabled={!config}
|
||||||
title={hasConfig ? 'Download full config template' : 'Load or create a config first'}
|
title="Download full config template"
|
||||||
>
|
>
|
||||||
Download
|
Download
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -14,13 +14,6 @@ export function ConfigTemplateEditor({ config, onSaved }: ConfigTemplateEditorPr
|
|||||||
const [originalContent, setOriginalContent] = useState(config.template.content);
|
const [originalContent, setOriginalContent] = useState(config.template.content);
|
||||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Validate placeholders for warning
|
|
||||||
const missingPlaceholders = config.validatePlaceholders();
|
|
||||||
const hasValidationWarnings = missingPlaceholders.length > 0;
|
|
||||||
const warningMessage = hasValidationWarnings
|
|
||||||
? `Missing params: ${missingPlaceholders.join(', ')}`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
// Sync draft when config changes (only in view mode)
|
// Sync draft when config changes (only in view mode)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode === 'view') {
|
if (mode === 'view') {
|
||||||
@@ -41,82 +34,33 @@ export function ConfigTemplateEditor({ config, onSaved }: ConfigTemplateEditorPr
|
|||||||
setMode('view');
|
setMode('view');
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateJson(value: string): boolean {
|
|
||||||
try {
|
|
||||||
if (!value.trim()) {
|
|
||||||
setJsonError(null);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Remove /* */ multi-line comments (safe, can't appear in strings)
|
|
||||||
let sanitized = value.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
||||||
|
|
||||||
// Step 2: Process line by line to handle // comments and placeholders
|
|
||||||
// Only remove // comments that are OUTSIDE of quoted strings
|
|
||||||
const lines = sanitized.split('\n');
|
|
||||||
const processedLines = lines.map(line => {
|
|
||||||
// Find // that's outside quotes
|
|
||||||
// Strategy: split by quotes, only process odd-indexed segments (outside quotes)
|
|
||||||
let result = '';
|
|
||||||
let inQuote = false;
|
|
||||||
let quoteChar = '';
|
|
||||||
|
|
||||||
for (let i = 0; i < line.length; i++) {
|
|
||||||
const char = line[i];
|
|
||||||
const prevChar = i > 0 ? line[i - 1] : '';
|
|
||||||
|
|
||||||
// Check for quote start/end (not escaped)
|
|
||||||
if ((char === '"' || char === "'") && prevChar !== '\\') {
|
|
||||||
if (!inQuote) {
|
|
||||||
inQuote = true;
|
|
||||||
quoteChar = char;
|
|
||||||
} else if (char === quoteChar) {
|
|
||||||
inQuote = false;
|
|
||||||
quoteChar = '';
|
|
||||||
}
|
|
||||||
result += char;
|
|
||||||
}
|
|
||||||
// Check for // comment start (only outside quotes)
|
|
||||||
else if (!inQuote && char === '/' && line[i + 1] === '/') {
|
|
||||||
// Rest of line is comment, stop processing
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
result += char;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
|
|
||||||
sanitized = processedLines.join('\n');
|
|
||||||
|
|
||||||
// Step 3: Replace unquoted @placeholders@ with dummy values
|
|
||||||
// Placeholders in JSON values are typically quoted: "@param@"
|
|
||||||
// We replace all @param@ with 1 (works for both quoted and unquoted)
|
|
||||||
sanitized = sanitized.replace(/@[^@]+@/g, '1');
|
|
||||||
|
|
||||||
JSON.parse(sanitized);
|
|
||||||
setJsonError(null);
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
setJsonError((e as Error).message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
// Validate JSON before saving (with comment and placeholder support)
|
// Validate JSON before saving (with placeholder support)
|
||||||
if (validateJson(draftContent)) {
|
try {
|
||||||
|
const sanitizedValue = draftContent.replace(/@[^@]+@/g, '1');
|
||||||
|
JSON.parse(sanitizedValue);
|
||||||
|
setJsonError(null);
|
||||||
onSaved(draftContent);
|
onSaved(draftContent);
|
||||||
setMode('view');
|
setMode('view');
|
||||||
|
} catch (e) {
|
||||||
|
setJsonError((e as Error).message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDraftChange(value: string) {
|
function handleDraftChange(value: string) {
|
||||||
setDraftContent(value);
|
setDraftContent(value);
|
||||||
// Validate JSON on every change
|
// Validate JSON on every change
|
||||||
validateJson(value);
|
try {
|
||||||
|
if (value.trim()) {
|
||||||
|
const sanitizedValue = value.replace(/@[^@]+@/g, '1');
|
||||||
|
JSON.parse(sanitizedValue);
|
||||||
|
setJsonError(null);
|
||||||
|
} else {
|
||||||
|
setJsonError(null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setJsonError((e as Error).message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
@@ -141,18 +85,12 @@ export function ConfigTemplateEditor({ config, onSaved }: ConfigTemplateEditorPr
|
|||||||
const isValidJson = jsonError === null;
|
const isValidJson = jsonError === null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="config-template-editor animate-fade-in h-full flex flex-col">
|
<div className="config-template-editor animate-fade-in">
|
||||||
{mode === 'view' ? (
|
{mode === 'view' ? (
|
||||||
<div className="space-y-3 flex flex-col h-full">
|
<div className="space-y-3">
|
||||||
<div className="flex-shrink-0 flex items-center justify-between flex-wrap gap-3">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="success">View Mode</Badge>
|
<Badge variant="success">View Mode</Badge>
|
||||||
{hasValidationWarnings && (
|
|
||||||
<div className="flex items-center gap-1.5 text-sm text-amber-600 bg-amber-50 px-2.5 py-1 rounded-md">
|
|
||||||
<span className="font-medium">Warning:</span>
|
|
||||||
<span>{warningMessage}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -163,14 +101,12 @@ export function ConfigTemplateEditor({ config, onSaved }: ConfigTemplateEditorPr
|
|||||||
Edit Template
|
Edit Template
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0">
|
<CodeBlock code={config.template.content || '{}'} language="json" maxHeight="500px" />
|
||||||
<CodeBlock code={config.template.content || '{}'} language="json" maxHeight="100%" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3 flex flex-col h-full">
|
<div className="space-y-3">
|
||||||
<div className="flex-shrink-0 flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
variant="success"
|
variant="success"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -180,7 +116,7 @@ export function ConfigTemplateEditor({ config, onSaved }: ConfigTemplateEditorPr
|
|||||||
>
|
>
|
||||||
Save Changes
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -203,17 +139,10 @@ export function ConfigTemplateEditor({ config, onSaved }: ConfigTemplateEditorPr
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
{hasValidationWarnings && isValidJson && (
|
|
||||||
<div className="flex items-center gap-1.5 text-sm text-amber-600 bg-amber-50 px-2.5 py-1 rounded-md">
|
|
||||||
<span className="font-medium">Warning:</span>
|
|
||||||
<span>{warningMessage}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{jsonError && (
|
{jsonError && (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 flex-shrink-0">
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||||
<p className="text-sm text-red-700 font-mono">{jsonError}</p>
|
<p className="text-sm text-red-700 font-mono">{jsonError}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -223,8 +152,8 @@ export function ConfigTemplateEditor({ config, onSaved }: ConfigTemplateEditorPr
|
|||||||
w-full p-3 font-mono text-sm rounded-lg border-2
|
w-full p-3 font-mono text-sm rounded-lg border-2
|
||||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||||
transition-all duration-200
|
transition-all duration-200
|
||||||
${isValidJson
|
${isValidJson
|
||||||
? 'border-green-300 bg-green-50'
|
? 'border-green-300 bg-green-50'
|
||||||
: 'border-red-300 bg-red-50'
|
: 'border-red-300 bg-red-50'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
@@ -232,7 +161,7 @@ export function ConfigTemplateEditor({ config, onSaved }: ConfigTemplateEditorPr
|
|||||||
onChange={(e) => handleDraftChange(e.target.value)}
|
onChange={(e) => handleDraftChange(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
rows={20}
|
rows={20}
|
||||||
style={{ whiteSpace: 'pre', overflowX: 'auto', flex: '1 1 auto', minHeight: '200px' }}
|
style={{ whiteSpace: 'pre', overflowX: 'auto' }}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ export function Content({ config, env, onTemplateSaved }: ContentProps) {
|
|||||||
|
|
||||||
const tabs: Array<{ id: string; label: string; badge?: string | number; badgeVariant?: 'warning' | 'danger' }> = [
|
const tabs: Array<{ id: string; label: string; badge?: string | number; badgeVariant?: 'warning' | 'danger' }> = [
|
||||||
{ id: 'env', label: 'Env' },
|
{ id: 'env', label: 'Env' },
|
||||||
{
|
{
|
||||||
id: 'template',
|
id: 'template',
|
||||||
label: 'Content Template',
|
label: 'Content Template',
|
||||||
badge: hasValidationWarnings ? '!' : undefined,
|
badge: hasValidationWarnings ? '!' : undefined,
|
||||||
badgeVariant: hasValidationWarnings ? 'warning' : undefined,
|
badgeVariant: hasValidationWarnings ? 'warning' : undefined,
|
||||||
@@ -31,24 +31,22 @@ export function Content({ config, env, onTemplateSaved }: ContentProps) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden h-full flex flex-col">
|
<div className="bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden">
|
||||||
<div className="flex-shrink-0">
|
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} />
|
||||||
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} />
|
|
||||||
</div>
|
<div className="p-4">
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 min-h-0">
|
|
||||||
<TabPanel isActive={activeTab === 'env'}>
|
<TabPanel isActive={activeTab === 'env'}>
|
||||||
<ContentParams env={env} />
|
<ContentParams env={env} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel isActive={activeTab === 'template'}>
|
<TabPanel isActive={activeTab === 'template'}>
|
||||||
<ConfigTemplateEditor config={config} onSaved={onTemplateSaved} />
|
<ConfigTemplateEditor config={config} onSaved={onTemplateSaved} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel isActive={activeTab === 'raw'}>
|
<TabPanel isActive={activeTab === 'raw'}>
|
||||||
<ContentRaw config={config} env={env} />
|
<ContentRaw config={config} env={env} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel isActive={activeTab === 'test'}>
|
<TabPanel isActive={activeTab === 'test'}>
|
||||||
<ContentTest config={config} env={env} />
|
<ContentTest config={config} env={env} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
@@ -59,10 +57,10 @@ export function Content({ config, env, onTemplateSaved }: ContentProps) {
|
|||||||
|
|
||||||
function ContentParams({ env }: { env: Env }) {
|
function ContentParams({ env }: { env: Env }) {
|
||||||
const xml = Builder.getEnv(env).build();
|
const xml = Builder.getEnv(env).build();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in h-full">
|
<div className="animate-fade-in">
|
||||||
<CodeBlock code={xml} language="xml" maxHeight="100%" />
|
<CodeBlock code={xml} language="xml" maxHeight="500px" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -79,8 +77,8 @@ ${templateContent}
|
|||||||
</engine>`;
|
</engine>`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in h-full">
|
<div className="animate-fade-in">
|
||||||
<CodeBlock code={xml} language="xml" maxHeight="100%" />
|
<CodeBlock code={xml} language="xml" maxHeight="500px" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -97,8 +95,8 @@ function ContentTest({ config, env }: { config: Config; env: Env }) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in space-y-4 h-full flex flex-col">
|
<div className="animate-fade-in space-y-4">
|
||||||
<div className="flex-shrink-0 flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-sm font-medium text-slate-700">Select Environment:</label>
|
<label className="text-sm font-medium text-slate-700">Select Environment:</label>
|
||||||
<select
|
<select
|
||||||
className="px-3 py-1.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="px-3 py-1.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
@@ -110,10 +108,8 @@ function ContentTest({ config, env }: { config: Config; env: Env }) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0">
|
<CodeBlock code={filledTemplate} language="json" maxHeight="500px" />
|
||||||
<CodeBlock code={filledTemplate} language="json" maxHeight="100%" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/componets/env/Environment.tsx
vendored
20
src/componets/env/Environment.tsx
vendored
@@ -94,10 +94,10 @@ export function Environment({ envs, onChanged, onSelected, onAdd, onRemove }: En
|
|||||||
));
|
));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card variant="bordered" padding="none" className="h-full overflow-hidden flex flex-col">
|
<Card variant="bordered" padding="none" className="h-full">
|
||||||
<CardBody className="space-y-2 flex flex-col h-full overflow-hidden">
|
<CardBody className="space-y-4">
|
||||||
{/* Environment Selector */}
|
{/* Environment Selector */}
|
||||||
<div className="flex-shrink-0 flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Select
|
<Select
|
||||||
label="Environment"
|
label="Environment"
|
||||||
@@ -112,7 +112,7 @@ export function Environment({ envs, onChanged, onSelected, onAdd, onRemove }: En
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col justify-center gap-2 pt-6 flex-shrink-0">
|
<div className="flex flex-col justify-center gap-2 pt-6">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="success"
|
variant="success"
|
||||||
@@ -134,15 +134,15 @@ export function Environment({ envs, onChanged, onSelected, onAdd, onRemove }: En
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Parameters Section - Scrollable */}
|
{/* Parameters Section */}
|
||||||
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-slate-700 mb-1 uppercase tracking-wide flex-shrink-0">
|
<h3 className="text-sm font-semibold text-slate-700 mb-3 uppercase tracking-wide">
|
||||||
Parameters
|
Parameters
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-0 pr-2 -mr-2">
|
<div className="space-y-2">
|
||||||
{paramCtrls}
|
{paramCtrls}
|
||||||
|
|
||||||
<EnvironmentParam
|
<EnvironmentParam
|
||||||
key={`${currEnv.id}-new`}
|
key={`${currEnv.id}-new`}
|
||||||
param={new EnvParam(-1, '', '')}
|
param={new EnvParam(-1, '', '')}
|
||||||
|
|||||||
6
src/componets/env/EnvironmentParam.tsx
vendored
6
src/componets/env/EnvironmentParam.tsx
vendored
@@ -48,7 +48,7 @@ export function EnvironmentParam({ param, onChanged, isNew }: EnvironmentParamPr
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
grid grid-cols-12 gap-1 p-1 rounded-lg transition-all duration-200
|
grid grid-cols-12 gap-2 p-2 rounded-lg transition-all duration-200
|
||||||
${isChangedClass}
|
${isChangedClass}
|
||||||
${focusedClass ? 'bg-blue-50' : 'bg-white'}
|
${focusedClass ? 'bg-blue-50' : 'bg-white'}
|
||||||
hover:bg-slate-50
|
hover:bg-slate-50
|
||||||
@@ -65,7 +65,7 @@ export function EnvironmentParam({ param, onChanged, isNew }: EnvironmentParamPr
|
|||||||
className="text-sm"
|
className="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-7">
|
<div className="col-span-7">
|
||||||
<Input
|
<Input
|
||||||
value={localParam.value ?? ''}
|
value={localParam.value ?? ''}
|
||||||
@@ -77,7 +77,7 @@ export function EnvironmentParam({ param, onChanged, isNew }: EnvironmentParamPr
|
|||||||
className="text-sm"
|
className="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-1 flex items-center justify-center">
|
<div className="col-span-1 flex items-center justify-center">
|
||||||
{isNew ? (
|
{isNew ? (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -97,48 +97,29 @@ export class Config {
|
|||||||
* Updates template by adding placeholders for environment params
|
* Updates template by adding placeholders for environment params
|
||||||
*/
|
*/
|
||||||
public updateTemplateFromEnv(env: Env): void {
|
public updateTemplateFromEnv(env: Env): void {
|
||||||
// If template is empty, initialize with empty object
|
let templateObj: Record<string, any> = {};
|
||||||
if (!this.template.content || !this.template.content.trim()) {
|
|
||||||
this.template = new ConfigTemplate('{}');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to parse existing template
|
// Try to parse existing template
|
||||||
let templateObj: Record<string, any> = {};
|
|
||||||
let hasExistingContent = false;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (this.template.content.trim()) {
|
if (this.template.content.trim()) {
|
||||||
templateObj = JSON.parse(this.template.content);
|
templateObj = JSON.parse(this.template.content);
|
||||||
hasExistingContent = Object.keys(templateObj).length > 0;
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// If invalid JSON, preserve the raw content and don't modify
|
// Start fresh if invalid JSON
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add placeholders for params that don't exist yet
|
// Add placeholders for params that don't exist yet
|
||||||
let hasChanges = false;
|
|
||||||
for (const param of env.params) {
|
for (const param of env.params) {
|
||||||
if (param.name && param.name.trim()) {
|
if (param.name && param.name.trim()) {
|
||||||
const paramName = param.name.trim();
|
const placeholder = `@${param.name}@`;
|
||||||
const placeholder = `@${paramName}@`;
|
|
||||||
const templateKey = `!!! ${paramName}`;
|
|
||||||
|
|
||||||
// Check if placeholder exists anywhere in template
|
|
||||||
if (!this.template.content.includes(placeholder)) {
|
if (!this.template.content.includes(placeholder)) {
|
||||||
// Only add if not already in templateObj
|
templateObj[`!!! ${param.name}`] = placeholder;
|
||||||
if (!templateObj[templateKey]) {
|
|
||||||
templateObj[templateKey] = placeholder;
|
|
||||||
hasChanges = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only update if there are actual changes
|
this.template = new ConfigTemplate(JSON.stringify(templateObj, null, 4));
|
||||||
if (hasChanges || !hasExistingContent) {
|
|
||||||
this.template = new ConfigTemplate(JSON.stringify(templateObj, null, 4));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -190,22 +171,4 @@ export class Config {
|
|||||||
cloned.template = this.template;
|
cloned.template = this.template;
|
||||||
return cloned;
|
return cloned;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if config is empty (no environments or only DEFAULT with no params and empty template)
|
|
||||||
*/
|
|
||||||
public isEmpty(): boolean {
|
|
||||||
if (this.envs.length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if only DEFAULT exists with no params
|
|
||||||
if (this.envs.length === 1 && this.envs[0].name === 'DEFAULT') {
|
|
||||||
const hasParams = this.envs[0].params.length > 0;
|
|
||||||
const hasTemplate = this.template.content.trim() && this.template.content !== '{}';
|
|
||||||
return !hasParams && !hasTemplate;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ const cfgTemplate = `
|
|||||||
|
|
||||||
|
|
||||||
test("read from a file", async ({expect})=>{
|
test("read from a file", async ({expect})=>{
|
||||||
const sut = new ConfigReader();
|
let sut = new ConfigReader();
|
||||||
const file = new File([cfgTemplate],'cfg.json.xml',{type:'application/xml'});
|
let file = new File([cfgTemplate],'cfg.json.xml',{type:'application/xml'});
|
||||||
|
|
||||||
// define a missing jsdom text() function,
|
// define a missing jsdom text() function,
|
||||||
// that presents in the real DOM
|
// that presents in the real DOM
|
||||||
@@ -46,15 +46,15 @@ test("read from a file", async ({expect})=>{
|
|||||||
writable: true
|
writable: true
|
||||||
});
|
});
|
||||||
|
|
||||||
const cfg = await sut.parseFromFile(file);
|
let cfg = await sut.parseFromFile(file);
|
||||||
|
|
||||||
expect(cfg).not.toBeUndefined();
|
expect(cfg).not.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("load environments and params", ({expect})=>{
|
test("load environments and params", ({expect})=>{
|
||||||
const sut = new ConfigReader();
|
let sut = new ConfigReader();
|
||||||
|
|
||||||
const cfg = sut.parseFromString(cfgTemplate);
|
let cfg = sut.parseFromString(cfgTemplate);
|
||||||
|
|
||||||
expect(cfg?.envs).toHaveLength(3);
|
expect(cfg?.envs).toHaveLength(3);
|
||||||
expect(cfg?.envs.map(x=>x.name))
|
expect(cfg?.envs.map(x=>x.name))
|
||||||
@@ -64,9 +64,9 @@ test("load environments and params", ({expect})=>{
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("load template", ({expect})=>{
|
test("load template", ({expect})=>{
|
||||||
const sut = new ConfigReader();
|
let sut = new ConfigReader();
|
||||||
|
|
||||||
const cfg = sut.parseFromString(cfgTemplate);
|
let cfg = sut.parseFromString(cfgTemplate);
|
||||||
|
|
||||||
expect(cfg?.template).toBeDefined();
|
expect(cfg?.template).toBeDefined();
|
||||||
expect(cfg?.template.content.length).toBeGreaterThan(20);
|
expect(cfg?.template.content.length).toBeGreaterThan(20);
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { Config } from '../models/Config';
|
|
||||||
import { Env } from '../models/Env';
|
|
||||||
import { EnvParam } from '../models/EnvParam';
|
|
||||||
|
|
||||||
describe('Config Template Update', () => {
|
|
||||||
it('should preserve existing template when adding new parameter', () => {
|
|
||||||
// Arrange: Create config with existing template
|
|
||||||
const config = new Config();
|
|
||||||
const existingTemplate = `{
|
|
||||||
"existingKey": "existingValue",
|
|
||||||
"anotherKey": 123
|
|
||||||
}`;
|
|
||||||
config.setTemplate(existingTemplate);
|
|
||||||
|
|
||||||
const env = new Env(0, 'DEFAULT', [
|
|
||||||
new EnvParam(1, 'host', 'localhost'),
|
|
||||||
new EnvParam(2, 'port', '8080')
|
|
||||||
]);
|
|
||||||
config.setEnvs([env]);
|
|
||||||
|
|
||||||
// Act: Update template from env (simulating adding parameters)
|
|
||||||
config.updateTemplateFromEnv(env);
|
|
||||||
|
|
||||||
// Assert: Template should have both existing content and new placeholders
|
|
||||||
const templateContent = config.template.content;
|
|
||||||
const templateObj = JSON.parse(templateContent);
|
|
||||||
|
|
||||||
expect(templateObj.existingKey).toBe('existingValue');
|
|
||||||
expect(templateObj.anotherKey).toBe(123);
|
|
||||||
expect(templateObj['!!! host']).toBe('@host@');
|
|
||||||
expect(templateObj['!!! port']).toBe('@port@');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not duplicate placeholders that already exist', () => {
|
|
||||||
// Arrange: Create config with template that already has placeholder
|
|
||||||
const config = new Config();
|
|
||||||
const existingTemplate = `{
|
|
||||||
"host": "@host@",
|
|
||||||
"port": 8080
|
|
||||||
}`;
|
|
||||||
config.setTemplate(existingTemplate);
|
|
||||||
|
|
||||||
const env = new Env(0, 'DEFAULT', [
|
|
||||||
new EnvParam(1, 'host', 'localhost'), // Already in template
|
|
||||||
new EnvParam(2, 'port', '8080') // Already in template
|
|
||||||
]);
|
|
||||||
config.setEnvs([env]);
|
|
||||||
|
|
||||||
// Act: Update template
|
|
||||||
config.updateTemplateFromEnv(env);
|
|
||||||
|
|
||||||
// Assert: Should not add !!! entries for existing placeholders
|
|
||||||
const templateContent = config.template.content;
|
|
||||||
// Count occurrences of @host@
|
|
||||||
const hostCount = (templateContent.match(/@host@/g) || []).length;
|
|
||||||
expect(hostCount).toBe(1); // Only original, no duplicate
|
|
||||||
|
|
||||||
// Should preserve original structure
|
|
||||||
expect(templateContent).toContain('"host": "@host@"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should initialize empty template', () => {
|
|
||||||
// Arrange: Create config with empty template
|
|
||||||
const config = new Config();
|
|
||||||
config.setTemplate('');
|
|
||||||
|
|
||||||
const env = new Env(0, 'DEFAULT', [
|
|
||||||
new EnvParam(1, 'host', 'localhost')
|
|
||||||
]);
|
|
||||||
config.setEnvs([env]);
|
|
||||||
|
|
||||||
// Act: Update template
|
|
||||||
config.updateTemplateFromEnv(env);
|
|
||||||
|
|
||||||
// Assert: Should create template with placeholder
|
|
||||||
const templateContent = config.template.content;
|
|
||||||
const templateObj = JSON.parse(templateContent);
|
|
||||||
expect(templateObj['!!! host']).toBe('@host@');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not modify invalid JSON template', () => {
|
|
||||||
// Arrange: Create config with invalid JSON template
|
|
||||||
const config = new Config();
|
|
||||||
const invalidTemplate = `{
|
|
||||||
"key": "value" // missing comma
|
|
||||||
"another": "key"
|
|
||||||
}`;
|
|
||||||
config.setTemplate(invalidTemplate);
|
|
||||||
const originalTemplate = config.template.content;
|
|
||||||
|
|
||||||
const env = new Env(0, 'DEFAULT', [
|
|
||||||
new EnvParam(1, 'host', 'localhost')
|
|
||||||
]);
|
|
||||||
config.setEnvs([env]);
|
|
||||||
|
|
||||||
// Act: Update template (should not modify invalid JSON)
|
|
||||||
config.updateTemplateFromEnv(env);
|
|
||||||
|
|
||||||
// Assert: Template should remain unchanged
|
|
||||||
expect(config.template.content).toBe(originalTemplate);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should add multiple new placeholders to existing template', () => {
|
|
||||||
// Arrange
|
|
||||||
const config = new Config();
|
|
||||||
const existingTemplate = `{
|
|
||||||
"name": "myapp"
|
|
||||||
}`;
|
|
||||||
config.setTemplate(existingTemplate);
|
|
||||||
|
|
||||||
const env = new Env(0, 'DEFAULT', [
|
|
||||||
new EnvParam(1, 'host', 'localhost'),
|
|
||||||
new EnvParam(2, 'port', '8080'),
|
|
||||||
new EnvParam(3, 'user', 'admin')
|
|
||||||
]);
|
|
||||||
config.setEnvs([env]);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
config.updateTemplateFromEnv(env);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
const templateObj = JSON.parse(config.template.content);
|
|
||||||
expect(templateObj.name).toBe('myapp');
|
|
||||||
expect(templateObj['!!! host']).toBe('@host@');
|
|
||||||
expect(templateObj['!!! port']).toBe('@port@');
|
|
||||||
expect(templateObj['!!! user']).toBe('@user@');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should only add placeholders for params not in template', () => {
|
|
||||||
// Arrange: Template has @host@ but not @port@
|
|
||||||
const config = new Config();
|
|
||||||
config.setTemplate(`{
|
|
||||||
"host": "@host@",
|
|
||||||
"name": "test"
|
|
||||||
}`);
|
|
||||||
|
|
||||||
const env = new Env(0, 'DEFAULT', [
|
|
||||||
new EnvParam(1, 'host', 'localhost'), // In template
|
|
||||||
new EnvParam(2, 'port', '8080') // Not in template
|
|
||||||
]);
|
|
||||||
config.setEnvs([env]);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
config.updateTemplateFromEnv(env);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
const templateContent = config.template.content;
|
|
||||||
const templateObj = JSON.parse(templateContent);
|
|
||||||
|
|
||||||
// Should preserve original
|
|
||||||
expect(templateObj.host).toBe('@host@');
|
|
||||||
expect(templateObj.name).toBe('test');
|
|
||||||
|
|
||||||
// Should add only missing
|
|
||||||
expect(templateObj['!!! port']).toBe('@port@');
|
|
||||||
expect(templateObj['!!! host']).toBeUndefined(); // Not added, already exists
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function that mimics the validation logic in ConfigTemplate.tsx
|
|
||||||
* This is the improved version with comment support
|
|
||||||
*/
|
|
||||||
function validateJsonWithComments(value: string): { valid: boolean; error?: string } {
|
|
||||||
try {
|
|
||||||
if (!value.trim()) {
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Remove /* */ multi-line comments
|
|
||||||
let sanitized = value.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
||||||
|
|
||||||
// Step 2: Process line by line to handle // comments outside quotes
|
|
||||||
const lines = sanitized.split('\n');
|
|
||||||
const processedLines = lines.map(line => {
|
|
||||||
let result = '';
|
|
||||||
let inQuote = false;
|
|
||||||
let quoteChar = '';
|
|
||||||
|
|
||||||
for (let i = 0; i < line.length; i++) {
|
|
||||||
const char = line[i];
|
|
||||||
const prevChar = i > 0 ? line[i - 1] : '';
|
|
||||||
|
|
||||||
// Check for quote start/end (not escaped)
|
|
||||||
if ((char === '"' || char === "'") && prevChar !== '\\') {
|
|
||||||
if (!inQuote) {
|
|
||||||
inQuote = true;
|
|
||||||
quoteChar = char;
|
|
||||||
} else if (char === quoteChar) {
|
|
||||||
inQuote = false;
|
|
||||||
quoteChar = '';
|
|
||||||
}
|
|
||||||
result += char;
|
|
||||||
}
|
|
||||||
// Check for // comment start (only outside quotes)
|
|
||||||
else if (!inQuote && char === '/' && line[i + 1] === '/') {
|
|
||||||
break; // Rest of line is comment
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
result += char;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
|
|
||||||
sanitized = processedLines.join('\n');
|
|
||||||
|
|
||||||
// Step 3: Replace @placeholders@ with dummy values
|
|
||||||
sanitized = sanitized.replace(/@[^@]+@/g, '1');
|
|
||||||
|
|
||||||
JSON.parse(sanitized);
|
|
||||||
return { valid: true };
|
|
||||||
} catch (e) {
|
|
||||||
return { valid: false, error: (e as Error).message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('JSON Validation - Comment Support', () => {
|
|
||||||
it('should remove single-line // comments outside quotes', () => {
|
|
||||||
const json = `{
|
|
||||||
"key": "value" // this is a comment
|
|
||||||
}`;
|
|
||||||
const result = validateJsonWithComments(json);
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve // inside quoted strings', () => {
|
|
||||||
const json = `{
|
|
||||||
"url": "http://example.com/path",
|
|
||||||
"description": "This // that // other"
|
|
||||||
}`;
|
|
||||||
const result = validateJsonWithComments(json);
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve URLs with // in values', () => {
|
|
||||||
const json = `{
|
|
||||||
"apiUrl": "http://localhost:8080/api",
|
|
||||||
"cdn": "https://cdn.example.com/assets"
|
|
||||||
}`;
|
|
||||||
const result = validateJsonWithComments(json);
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should remove multi-line /* */ comments', () => {
|
|
||||||
const json = `{
|
|
||||||
/* This is a
|
|
||||||
multi-line comment */
|
|
||||||
"key": "value"
|
|
||||||
}`;
|
|
||||||
const result = validateJsonWithComments(json);
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle mixed comments and placeholders', () => {
|
|
||||||
const json = `{
|
|
||||||
// Host configuration
|
|
||||||
"host": "@host@",
|
|
||||||
"port": @port@, // unquoted placeholder
|
|
||||||
/* API settings */
|
|
||||||
"apiUrl": "http://@host@:@port@/api" // URL with placeholders
|
|
||||||
}`;
|
|
||||||
const result = validateJsonWithComments(json);
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle comment-only lines', () => {
|
|
||||||
const json = `{
|
|
||||||
// Just a comment
|
|
||||||
"key": "value"
|
|
||||||
// Another comment
|
|
||||||
}`;
|
|
||||||
const result = validateJsonWithComments(json);
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve // in string values with special characters', () => {
|
|
||||||
const json = `{
|
|
||||||
"regex": "pattern // with slashes",
|
|
||||||
"path": "C://Program Files//App",
|
|
||||||
"comment": "text // more text // even more"
|
|
||||||
}`;
|
|
||||||
const result = validateJsonWithComments(json);
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle escaped quotes correctly', () => {
|
|
||||||
const json = `{
|
|
||||||
"message": "He said \\"hello // world\\""
|
|
||||||
}`;
|
|
||||||
const result = validateJsonWithComments(json);
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject invalid JSON with comments', () => {
|
|
||||||
const json = `{
|
|
||||||
"key": "value" // missing comma
|
|
||||||
"another": "key"
|
|
||||||
}`;
|
|
||||||
const result = validateJsonWithComments(json);
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.error).toContain('Expected');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle empty template', () => {
|
|
||||||
const result = validateJsonWithComments('');
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle whitespace only', () => {
|
|
||||||
const result = validateJsonWithComments(' \n\t ');
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('JSON Validation - Placeholder Support', () => {
|
|
||||||
it('should validate quoted placeholders', () => {
|
|
||||||
const json = `{
|
|
||||||
"Host": "@host@",
|
|
||||||
"Port": "@port@"
|
|
||||||
}`;
|
|
||||||
const result = validateJsonWithComments(json);
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate unquoted placeholders', () => {
|
|
||||||
const json = `{
|
|
||||||
"Host": "@host@",
|
|
||||||
"Port": @port@
|
|
||||||
}`;
|
|
||||||
const result = validateJsonWithComments(json);
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate placeholders in URLs', () => {
|
|
||||||
const json = `{
|
|
||||||
"ApiUrl": "http://@host@:@port@/api/v1"
|
|
||||||
}`;
|
|
||||||
const result = validateJsonWithComments(json);
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate complex template with comments and placeholders', () => {
|
|
||||||
const json = `{
|
|
||||||
// Database config
|
|
||||||
"db": {
|
|
||||||
"host": "@db_host@",
|
|
||||||
"port": @db_port@,
|
|
||||||
"url": "jdbc:mysql://@db_host@:@db_port@/mydb" // connection string
|
|
||||||
},
|
|
||||||
/* API settings */
|
|
||||||
"api": {
|
|
||||||
"baseUrl": "https://@api_host@/v1",
|
|
||||||
"timeout": @timeout@
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
const result = validateJsonWithComments(json);
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user