init
This commit is contained in:
48
.github/workflows/deploy.yml
vendored
Normal file
48
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@v5
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: dist
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
*.tsbuildinfo
|
||||
vite.config.d.ts
|
||||
vite.config.js
|
||||
.DS_Store
|
||||
*.local
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Meghdad
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
54
README.md
Normal file
54
README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# CORS Test Lab
|
||||
|
||||
A static, browser-based dashboard for testing real CORS behavior from the page origin. It is built for GitHub Pages and does not need a backend, proxy, database, API key, or server runtime.
|
||||
|
||||
## Features
|
||||
|
||||
- Request builder with URL, method, mode, credentials, timeout, headers, content type, and body.
|
||||
- One-click probes for simple GET, JSON POST, credentials, custom headers, DELETE, and `no-cors`.
|
||||
- Preflight detection based on method, headers, and content type.
|
||||
- Result view with status, timing, visible response headers, readable body preview, and browser error classification.
|
||||
- CORS diagnostics for failed preflights, missing readable responses, credentialed CORS pitfalls, mixed content, and opaque `no-cors` behavior.
|
||||
- Saved test cases in `localStorage`, with JSON import/export.
|
||||
- Copyable diagnostic report for API teams.
|
||||
|
||||
## Important Browser Limits
|
||||
|
||||
This app tests CORS exactly as a browser enforces it. It cannot bypass CORS.
|
||||
|
||||
- If the browser blocks a CORS response, JavaScript cannot read the blocked response headers or body.
|
||||
- Browser-managed headers such as `Origin`, `Host`, `Cookie`, `Referer`, and `Sec-*` cannot be manually set.
|
||||
- `no-cors` requests can produce opaque responses where status, headers, and body are intentionally hidden.
|
||||
- Testing from GitHub Pages means your API sees the app origin as `https://<user>.github.io`.
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Build for production:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Preview the production build:
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Publish to GitHub Pages
|
||||
|
||||
1. Push this repository to GitHub.
|
||||
2. In the GitHub repository, open `Settings > Pages`.
|
||||
3. Set `Build and deployment > Source` to `GitHub Actions`.
|
||||
4. Push to the `main` branch, or run the `Deploy to GitHub Pages` workflow manually.
|
||||
|
||||
The workflow builds the static app with Vite and deploys the `dist` artifact to GitHub Pages.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
24
index.html
Normal file
24
index.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
<meta
|
||||
name="description"
|
||||
content="A static browser-based CORS testing dashboard for API teams."
|
||||
/>
|
||||
<meta property="og:title" content="CORS Test Lab" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Test real browser CORS behavior from a static GitHub Pages app."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<link rel="icon" type="image/svg+xml" href="./cors-lab.svg" />
|
||||
<title>CORS Test Lab</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1218
package-lock.json
generated
Normal file
1218
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "cors-test",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "A static browser-based CORS testing dashboard for API teams.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
1
public/.nojekyll
Normal file
1
public/.nojekyll
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
7
public/cors-lab.svg
Normal file
7
public/cors-lab.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="CORS Test Lab icon">
|
||||
<rect width="64" height="64" rx="14" fill="#2563eb"/>
|
||||
<path d="M18 20h28a4 4 0 0 1 4 4v16a4 4 0 0 1-4 4H18a4 4 0 0 1-4-4V24a4 4 0 0 1 4-4Z" fill="#eff6ff"/>
|
||||
<path d="M23 32h18M34 25l8 7-8 7" fill="none" stroke="#2563eb" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="18" cy="20" r="5" fill="#16a34a"/>
|
||||
<circle cx="46" cy="44" r="5" fill="#f97316"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 501 B |
836
src/App.tsx
Normal file
836
src/App.tsx
Normal file
@@ -0,0 +1,836 @@
|
||||
import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
|
||||
type CorsMode = 'cors' | 'no-cors' | 'same-origin';
|
||||
type CredentialsMode = 'omit' | 'same-origin' | 'include';
|
||||
|
||||
type HeaderRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type TestDraft = {
|
||||
url: string;
|
||||
method: HttpMethod;
|
||||
mode: CorsMode;
|
||||
credentials: CredentialsMode;
|
||||
contentType: string;
|
||||
body: string;
|
||||
timeoutMs: number;
|
||||
headers: HeaderRow[];
|
||||
};
|
||||
|
||||
type SavedCase = TestDraft & {
|
||||
id: string;
|
||||
name: string;
|
||||
savedAt: string;
|
||||
};
|
||||
|
||||
type TestResult = {
|
||||
id: string;
|
||||
label: string;
|
||||
startedAt: string;
|
||||
durationMs: number;
|
||||
status: 'passed' | 'failed' | 'opaque';
|
||||
browserResult: string;
|
||||
statusCode?: number;
|
||||
statusText?: string;
|
||||
responseType?: ResponseType;
|
||||
responseUrl?: string;
|
||||
headers: Array<[string, string]>;
|
||||
bodyPreview?: string;
|
||||
error?: string;
|
||||
warnings: string[];
|
||||
diagnostics: string[];
|
||||
report: string;
|
||||
};
|
||||
|
||||
type Scenario = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
overrides: Partial<TestDraft>;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'cors-test-lab-cases-v1';
|
||||
|
||||
const METHODS: HttpMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
|
||||
const MODES: CorsMode[] = ['cors', 'no-cors', 'same-origin'];
|
||||
const CREDENTIALS: CredentialsMode[] = ['omit', 'same-origin', 'include'];
|
||||
const CONTENT_TYPES = [
|
||||
{ label: 'None', value: '' },
|
||||
{ label: 'JSON', value: 'application/json' },
|
||||
{ label: 'Text', value: 'text/plain' },
|
||||
{ label: 'Form URL encoded', value: 'application/x-www-form-urlencoded' },
|
||||
{ label: 'Multipart form', value: 'multipart/form-data' },
|
||||
];
|
||||
|
||||
const defaultDraft: TestDraft = {
|
||||
url: '',
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
credentials: 'omit',
|
||||
contentType: '',
|
||||
body: '',
|
||||
timeoutMs: 12000,
|
||||
headers: [{ id: crypto.randomUUID(), name: '', value: '' }],
|
||||
};
|
||||
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
id: 'simple-get',
|
||||
title: 'Simple GET',
|
||||
description: 'Checks the common no-preflight read path.',
|
||||
overrides: { method: 'GET', mode: 'cors', credentials: 'omit', body: '', contentType: '' },
|
||||
},
|
||||
{
|
||||
id: 'json-post',
|
||||
title: 'JSON POST',
|
||||
description: 'Forces a preflight through application/json.',
|
||||
overrides: {
|
||||
method: 'POST',
|
||||
mode: 'cors',
|
||||
credentials: 'omit',
|
||||
contentType: 'application/json',
|
||||
body: '{\n "cors": true\n}',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'credentials',
|
||||
title: 'Credentials',
|
||||
description: 'Tests cookies/auth across origins.',
|
||||
overrides: { method: 'GET', mode: 'cors', credentials: 'include', body: '', contentType: '' },
|
||||
},
|
||||
{
|
||||
id: 'custom-header',
|
||||
title: 'Custom header',
|
||||
description: 'Validates Access-Control-Allow-Headers.',
|
||||
overrides: {
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
credentials: 'omit',
|
||||
body: '',
|
||||
contentType: '',
|
||||
headers: [{ id: crypto.randomUUID(), name: 'X-CORS-Test', value: '1' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
title: 'DELETE',
|
||||
description: 'Validates allowed non-simple methods.',
|
||||
overrides: { method: 'DELETE', mode: 'cors', credentials: 'omit', body: '', contentType: '' },
|
||||
},
|
||||
{
|
||||
id: 'opaque',
|
||||
title: 'No-CORS probe',
|
||||
description: 'Confirms the browser can send but not read.',
|
||||
overrides: { method: 'GET', mode: 'no-cors', credentials: 'omit', body: '', contentType: '' },
|
||||
},
|
||||
];
|
||||
|
||||
const forbiddenHeaders = new Set([
|
||||
'accept-charset',
|
||||
'accept-encoding',
|
||||
'access-control-request-headers',
|
||||
'access-control-request-method',
|
||||
'connection',
|
||||
'content-length',
|
||||
'cookie',
|
||||
'date',
|
||||
'dnt',
|
||||
'expect',
|
||||
'host',
|
||||
'keep-alive',
|
||||
'origin',
|
||||
'referer',
|
||||
'te',
|
||||
'trailer',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
'via',
|
||||
]);
|
||||
|
||||
function isForbiddenHeader(name: string) {
|
||||
const lower = name.toLowerCase();
|
||||
return forbiddenHeaders.has(lower) || lower.startsWith('proxy-') || lower.startsWith('sec-');
|
||||
}
|
||||
|
||||
function isSimpleContentType(value: string) {
|
||||
const normalized = value.split(';')[0].trim().toLowerCase();
|
||||
return ['', 'application/x-www-form-urlencoded', 'multipart/form-data', 'text/plain'].includes(normalized);
|
||||
}
|
||||
|
||||
function isSafelistedHeader(name: string, value: string) {
|
||||
const lower = name.toLowerCase();
|
||||
if (['accept', 'accept-language', 'content-language'].includes(lower)) {
|
||||
return true;
|
||||
}
|
||||
if (lower === 'content-type') {
|
||||
return isSimpleContentType(value);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function expectsPreflight(draft: TestDraft, appliedHeaders: Array<[string, string]>) {
|
||||
const simpleMethod = ['GET', 'HEAD', 'POST'].includes(draft.method);
|
||||
const hasNonSimpleHeader = appliedHeaders.some(([name, value]) => !isSafelistedHeader(name, value));
|
||||
const hasNonSimpleContentType = draft.contentType ? !isSimpleContentType(draft.contentType) : false;
|
||||
return draft.mode === 'cors' && (!simpleMethod || hasNonSimpleHeader || hasNonSimpleContentType);
|
||||
}
|
||||
|
||||
function buildHeaders(draft: TestDraft) {
|
||||
const warnings: string[] = [];
|
||||
const applied: Array<[string, string]> = [];
|
||||
const names = new Set<string>();
|
||||
|
||||
draft.headers.forEach((header) => {
|
||||
const name = header.name.trim();
|
||||
const value = header.value.trim();
|
||||
if (!name || !value) {
|
||||
return;
|
||||
}
|
||||
if (isForbiddenHeader(name)) {
|
||||
warnings.push(`Browser-managed header "${name}" was skipped because Fetch cannot set it.`);
|
||||
return;
|
||||
}
|
||||
names.add(name.toLowerCase());
|
||||
applied.push([name, value]);
|
||||
});
|
||||
|
||||
const canHaveBody = !['GET', 'HEAD'].includes(draft.method);
|
||||
if (canHaveBody && draft.contentType && draft.body.trim() && !names.has('content-type')) {
|
||||
applied.push(['Content-Type', draft.contentType]);
|
||||
}
|
||||
|
||||
return { applied, warnings };
|
||||
}
|
||||
|
||||
function parseTargetUrl(value: string) {
|
||||
try {
|
||||
return new URL(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getOriginLabel() {
|
||||
if (window.location.origin === 'null') {
|
||||
return 'local file or sandboxed origin';
|
||||
}
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
function makeDiagnostics(
|
||||
draft: TestDraft,
|
||||
result: Pick<TestResult, 'status' | 'responseType' | 'statusCode' | 'error'>,
|
||||
appliedHeaders: Array<[string, string]>,
|
||||
) {
|
||||
const diagnostics: string[] = [];
|
||||
const target = parseTargetUrl(draft.url);
|
||||
const preflight = expectsPreflight(draft, appliedHeaders);
|
||||
|
||||
if (!target) {
|
||||
diagnostics.push('The URL is not valid. Use an absolute http:// or https:// API URL.');
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
if (window.location.protocol === 'https:' && target.protocol === 'http:') {
|
||||
diagnostics.push('This is mixed content: an HTTPS page cannot safely call an HTTP API in modern browsers.');
|
||||
}
|
||||
|
||||
if (draft.mode === 'same-origin' && target.origin !== window.location.origin) {
|
||||
diagnostics.push('same-origin mode rejects cross-origin URLs before a CORS exchange can complete.');
|
||||
}
|
||||
|
||||
if (draft.mode === 'no-cors') {
|
||||
diagnostics.push('no-cors mode creates an opaque response. The request may be sent, but status, headers, and body are intentionally hidden.');
|
||||
}
|
||||
|
||||
if (draft.credentials === 'include') {
|
||||
diagnostics.push('Credentialed CORS requires a specific Access-Control-Allow-Origin value and Access-Control-Allow-Credentials: true. Wildcard origins are not allowed.');
|
||||
}
|
||||
|
||||
if (preflight) {
|
||||
diagnostics.push('This request should trigger a browser preflight because it uses a non-simple method, header, or content type.');
|
||||
} else if (draft.mode === 'cors') {
|
||||
diagnostics.push('This request is shaped like a simple CORS request and may avoid preflight.');
|
||||
}
|
||||
|
||||
if (result.status === 'failed') {
|
||||
diagnostics.push('The browser blocked or failed the fetch before JavaScript could read the response. Common causes are missing CORS headers, failed preflight, TLS/DNS/network failure, blocked redirect, or mixed content.');
|
||||
if (preflight) {
|
||||
diagnostics.push('Check that OPTIONS responds with the requested method and headers in Access-Control-Allow-Methods and Access-Control-Allow-Headers.');
|
||||
}
|
||||
}
|
||||
|
||||
if (result.status === 'passed') {
|
||||
diagnostics.push('JavaScript could read the response, so the browser accepted the CORS policy for this request.');
|
||||
diagnostics.push('Only safelisted response headers and headers named in Access-Control-Expose-Headers are visible here.');
|
||||
}
|
||||
|
||||
if (result.responseType === 'opaque') {
|
||||
diagnostics.push('Opaque responses always show status 0 and no readable headers or body by design.');
|
||||
}
|
||||
|
||||
if (typeof result.statusCode === 'number' && result.statusCode >= 400) {
|
||||
diagnostics.push('CORS passed, but the API returned an HTTP error status. Debug the application response separately from CORS.');
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function makeReport(draft: TestDraft, result: Omit<TestResult, 'report'>, appliedHeaders: Array<[string, string]>) {
|
||||
const headerLines = appliedHeaders.length
|
||||
? appliedHeaders.map(([name, value]) => ` ${name}: ${value}`).join('\n')
|
||||
: ' (none)';
|
||||
const responseHeaderLines = result.headers.length
|
||||
? result.headers.map(([name, value]) => ` ${name}: ${value}`).join('\n')
|
||||
: ' (none visible)';
|
||||
const diagnostics = result.diagnostics.map((item) => `- ${item}`).join('\n');
|
||||
|
||||
return [
|
||||
'CORS Test Lab report',
|
||||
`Generated: ${new Date().toISOString()}`,
|
||||
`Tester origin: ${getOriginLabel()}`,
|
||||
'',
|
||||
'Request',
|
||||
` URL: ${draft.url}`,
|
||||
` Method: ${draft.method}`,
|
||||
` Mode: ${draft.mode}`,
|
||||
` Credentials: ${draft.credentials}`,
|
||||
` Preflight expected: ${expectsPreflight(draft, appliedHeaders) ? 'yes' : 'no'}`,
|
||||
' Headers:',
|
||||
headerLines,
|
||||
draft.body.trim() ? ` Body: ${draft.body}` : ' Body: (empty)',
|
||||
'',
|
||||
'Result',
|
||||
` Status: ${result.status}`,
|
||||
` Browser result: ${result.browserResult}`,
|
||||
` HTTP status: ${result.statusCode ?? 'not readable'}`,
|
||||
` Response type: ${result.responseType ?? 'not available'}`,
|
||||
` Duration: ${result.durationMs}ms`,
|
||||
result.error ? ` Error: ${result.error}` : '',
|
||||
'',
|
||||
'Visible response headers',
|
||||
responseHeaderLines,
|
||||
'',
|
||||
'Diagnostics',
|
||||
diagnostics || '- No diagnostics generated.',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function emptyHeader(): HeaderRow {
|
||||
return { id: crypto.randomUUID(), name: '', value: '' };
|
||||
}
|
||||
|
||||
function normalizedDraft(draft: TestDraft): TestDraft {
|
||||
return {
|
||||
...draft,
|
||||
timeoutMs: Number.isFinite(draft.timeoutMs) ? Math.max(1000, draft.timeoutMs) : 12000,
|
||||
headers: draft.headers.length ? draft.headers : [emptyHeader()],
|
||||
};
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [draft, setDraft] = useState<TestDraft>(defaultDraft);
|
||||
const [caseName, setCaseName] = useState('');
|
||||
const [savedCases, setSavedCases] = useState<SavedCase[]>([]);
|
||||
const [results, setResults] = useState<TestResult[]>([]);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as SavedCase[];
|
||||
if (Array.isArray(parsed)) {
|
||||
setSavedCases(parsed);
|
||||
}
|
||||
} catch {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(savedCases));
|
||||
}, [savedCases]);
|
||||
|
||||
const headerSummary = useMemo(() => buildHeaders(draft), [draft]);
|
||||
const preflightExpected = useMemo(
|
||||
() => expectsPreflight(draft, headerSummary.applied),
|
||||
[draft, headerSummary.applied],
|
||||
);
|
||||
|
||||
const latestResult = results[0];
|
||||
|
||||
function patchDraft(patch: Partial<TestDraft>) {
|
||||
setDraft((current) => normalizedDraft({ ...current, ...patch }));
|
||||
}
|
||||
|
||||
function updateHeader(id: string, field: 'name' | 'value', value: string) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
headers: current.headers.map((header) =>
|
||||
header.id === id ? { ...header, [field]: value } : header,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function removeHeader(id: string) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
headers: current.headers.length === 1 ? [emptyHeader()] : current.headers.filter((header) => header.id !== id),
|
||||
}));
|
||||
}
|
||||
|
||||
async function runTest(overrides: Partial<TestDraft> = {}, label = 'Manual test') {
|
||||
const nextDraft = normalizedDraft({ ...draft, ...overrides });
|
||||
setDraft(nextDraft);
|
||||
setIsRunning(true);
|
||||
|
||||
const started = performance.now();
|
||||
const startedAt = new Date().toISOString();
|
||||
const { applied, warnings } = buildHeaders(nextDraft);
|
||||
const headers = new Headers(applied);
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), nextDraft.timeoutMs);
|
||||
const canHaveBody = !['GET', 'HEAD'].includes(nextDraft.method);
|
||||
|
||||
try {
|
||||
if (!parseTargetUrl(nextDraft.url)) {
|
||||
throw new Error('Invalid URL. Enter an absolute http:// or https:// API URL.');
|
||||
}
|
||||
|
||||
const response = await fetch(nextDraft.url, {
|
||||
method: nextDraft.method,
|
||||
mode: nextDraft.mode,
|
||||
credentials: nextDraft.credentials,
|
||||
redirect: 'follow',
|
||||
headers,
|
||||
body: canHaveBody && nextDraft.body.trim() ? nextDraft.body : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
let bodyPreview = '';
|
||||
try {
|
||||
bodyPreview = await response.text();
|
||||
} catch (error) {
|
||||
bodyPreview = error instanceof Error ? `Body is not readable: ${error.message}` : 'Body is not readable.';
|
||||
}
|
||||
|
||||
const elapsed = Math.round(performance.now() - started);
|
||||
const responseHeaders = Array.from(response.headers.entries());
|
||||
const status: TestResult['status'] = response.type === 'opaque' ? 'opaque' : 'passed';
|
||||
const draftResult = {
|
||||
id: crypto.randomUUID(),
|
||||
label,
|
||||
startedAt,
|
||||
durationMs: elapsed,
|
||||
status,
|
||||
browserResult: response.type === 'opaque' ? 'Opaque no-cors response' : 'Readable response',
|
||||
statusCode: response.type === 'opaque' ? undefined : response.status,
|
||||
statusText: response.type === 'opaque' ? undefined : response.statusText,
|
||||
responseType: response.type,
|
||||
responseUrl: response.url,
|
||||
headers: responseHeaders,
|
||||
bodyPreview: bodyPreview.slice(0, 6000),
|
||||
warnings,
|
||||
diagnostics: [] as string[],
|
||||
};
|
||||
const diagnostics = makeDiagnostics(nextDraft, draftResult, applied);
|
||||
const complete = { ...draftResult, diagnostics };
|
||||
const report = makeReport(nextDraft, complete, applied);
|
||||
setResults((current) => [{ ...complete, report }, ...current].slice(0, 20));
|
||||
} catch (error) {
|
||||
const elapsed = Math.round(performance.now() - started);
|
||||
const message =
|
||||
error instanceof DOMException && error.name === 'AbortError'
|
||||
? `Timed out after ${nextDraft.timeoutMs}ms`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown fetch error';
|
||||
const draftResult = {
|
||||
id: crypto.randomUUID(),
|
||||
label,
|
||||
startedAt,
|
||||
durationMs: elapsed,
|
||||
status: 'failed' as const,
|
||||
browserResult: 'Fetch failed before JavaScript could read the response',
|
||||
headers: [] as Array<[string, string]>,
|
||||
error: message,
|
||||
warnings,
|
||||
diagnostics: [] as string[],
|
||||
};
|
||||
const diagnostics = makeDiagnostics(nextDraft, draftResult, applied);
|
||||
const complete = { ...draftResult, diagnostics };
|
||||
const report = makeReport(nextDraft, complete, applied);
|
||||
setResults((current) => [{ ...complete, report }, ...current].slice(0, 20));
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
setIsRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
function saveCase() {
|
||||
const name = caseName.trim() || `${draft.method} ${draft.url || 'Untitled API'}`;
|
||||
const saved: SavedCase = {
|
||||
...normalizedDraft(draft),
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
setSavedCases((current) => [saved, ...current]);
|
||||
setCaseName('');
|
||||
}
|
||||
|
||||
function loadCase(saved: SavedCase) {
|
||||
const { id: _id, name: _name, savedAt: _savedAt, ...caseDraft } = saved;
|
||||
setDraft(normalizedDraft(caseDraft));
|
||||
}
|
||||
|
||||
function exportCases() {
|
||||
const blob = new Blob([JSON.stringify(savedCases, null, 2)], { type: 'application/json' });
|
||||
const href = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = href;
|
||||
link.download = 'cors-test-cases.json';
|
||||
link.click();
|
||||
URL.revokeObjectURL(href);
|
||||
}
|
||||
|
||||
async function importCases(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(await file.text()) as SavedCase[];
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('Import file must contain an array of saved cases.');
|
||||
}
|
||||
const sanitized = parsed.map((item) => ({
|
||||
...normalizedDraft(item),
|
||||
id: item.id || crypto.randomUUID(),
|
||||
name: item.name || `${item.method} ${item.url}`,
|
||||
savedAt: item.savedAt || new Date().toISOString(),
|
||||
}));
|
||||
setSavedCases((current) => [...sanitized, ...current]);
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : 'Could not import cases.');
|
||||
} finally {
|
||||
event.target.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function copyReport(result: TestResult) {
|
||||
await navigator.clipboard.writeText(result.report);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<section className="hero">
|
||||
<div>
|
||||
<p className="eyebrow">Static browser CORS diagnostics</p>
|
||||
<h1>CORS Test Lab</h1>
|
||||
<p className="hero-copy">
|
||||
Test real browser CORS behavior from this page origin, classify preflights, inspect readable responses,
|
||||
and generate reports for API owners.
|
||||
</p>
|
||||
</div>
|
||||
<div className="origin-panel">
|
||||
<span>Testing origin</span>
|
||||
<strong>{getOriginLabel()}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="dashboard">
|
||||
<div className="request-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Request builder</p>
|
||||
<h2>Configure a test</h2>
|
||||
</div>
|
||||
<button className="primary" disabled={isRunning} onClick={() => void runTest()}>
|
||||
{isRunning ? 'Running...' : 'Run test'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="field field-full">
|
||||
<span>API URL</span>
|
||||
<input
|
||||
value={draft.url}
|
||||
onChange={(event) => patchDraft({ url: event.target.value })}
|
||||
placeholder="https://api.example.com/v1/resource"
|
||||
inputMode="url"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="control-grid">
|
||||
<label className="field">
|
||||
<span>Method</span>
|
||||
<select value={draft.method} onChange={(event) => patchDraft({ method: event.target.value as HttpMethod })}>
|
||||
{METHODS.map((method) => (
|
||||
<option key={method} value={method}>
|
||||
{method}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Mode</span>
|
||||
<select value={draft.mode} onChange={(event) => patchDraft({ mode: event.target.value as CorsMode })}>
|
||||
{MODES.map((mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{mode}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Credentials</span>
|
||||
<select
|
||||
value={draft.credentials}
|
||||
onChange={(event) => patchDraft({ credentials: event.target.value as CredentialsMode })}
|
||||
>
|
||||
{CREDENTIALS.map((credentials) => (
|
||||
<option key={credentials} value={credentials}>
|
||||
{credentials}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Timeout</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1000"
|
||||
step="500"
|
||||
value={draft.timeoutMs}
|
||||
onChange={(event) => patchDraft({ timeoutMs: Number(event.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="status-strip">
|
||||
<span className={preflightExpected ? 'pill warning' : 'pill ok'}>
|
||||
{preflightExpected ? 'Preflight expected' : 'Simple request shape'}
|
||||
</span>
|
||||
<span className="pill">Mode: {draft.mode}</span>
|
||||
<span className="pill">Credentials: {draft.credentials}</span>
|
||||
</div>
|
||||
|
||||
<div className="section-subhead">
|
||||
<h3>Headers</h3>
|
||||
<button className="ghost" onClick={() => patchDraft({ headers: [...draft.headers, emptyHeader()] })}>
|
||||
Add header
|
||||
</button>
|
||||
</div>
|
||||
<div className="headers-list">
|
||||
{draft.headers.map((header) => (
|
||||
<div className="header-row" key={header.id}>
|
||||
<input
|
||||
value={header.name}
|
||||
onChange={(event) => updateHeader(header.id, 'name', event.target.value)}
|
||||
placeholder="Header name"
|
||||
/>
|
||||
<input
|
||||
value={header.value}
|
||||
onChange={(event) => updateHeader(header.id, 'value', event.target.value)}
|
||||
placeholder="Value"
|
||||
/>
|
||||
<button className="icon-button" aria-label="Remove header" onClick={() => removeHeader(header.id)}>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="control-grid body-grid">
|
||||
<label className="field">
|
||||
<span>Content type</span>
|
||||
<select value={draft.contentType} onChange={(event) => patchDraft({ contentType: event.target.value })}>
|
||||
{CONTENT_TYPES.map((contentType) => (
|
||||
<option key={contentType.value} value={contentType.value}>
|
||||
{contentType.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field field-body">
|
||||
<span>Body</span>
|
||||
<textarea
|
||||
value={draft.body}
|
||||
onChange={(event) => patchDraft({ body: event.target.value })}
|
||||
placeholder='{"example": true}'
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="side-panel">
|
||||
<div className="section-heading compact">
|
||||
<div>
|
||||
<p className="eyebrow">Scenarios</p>
|
||||
<h2>One-click probes</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="scenario-grid">
|
||||
{scenarios.map((scenario) => (
|
||||
<button
|
||||
className="scenario-card"
|
||||
key={scenario.id}
|
||||
disabled={isRunning}
|
||||
onClick={() => void runTest(scenario.overrides, scenario.title)}
|
||||
>
|
||||
<strong>{scenario.title}</strong>
|
||||
<span>{scenario.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="saved-panel">
|
||||
<div className="section-subhead">
|
||||
<h3>Saved cases</h3>
|
||||
<div className="button-pair">
|
||||
<button className="ghost" disabled={!savedCases.length} onClick={exportCases}>
|
||||
Export
|
||||
</button>
|
||||
<button className="ghost" onClick={() => fileInputRef.current?.click()}>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input ref={fileInputRef} hidden type="file" accept="application/json" onChange={(event) => void importCases(event)} />
|
||||
<div className="save-row">
|
||||
<input value={caseName} onChange={(event) => setCaseName(event.target.value)} placeholder="Case name" />
|
||||
<button className="secondary" onClick={saveCase}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<div className="saved-list">
|
||||
{savedCases.length === 0 ? (
|
||||
<p className="muted">No saved cases yet.</p>
|
||||
) : (
|
||||
savedCases.map((saved) => (
|
||||
<div className="saved-item" key={saved.id}>
|
||||
<button onClick={() => loadCase(saved)}>
|
||||
<strong>{saved.name}</strong>
|
||||
<span>
|
||||
{saved.method} {saved.url || 'No URL'}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
aria-label={`Delete ${saved.name}`}
|
||||
onClick={() => setSavedCases((current) => current.filter((item) => item.id !== saved.id))}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section className="results-layout">
|
||||
<div className="result-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Latest result</p>
|
||||
<h2>{latestResult ? latestResult.label : 'No test run yet'}</h2>
|
||||
</div>
|
||||
{latestResult ? (
|
||||
<button className="secondary" onClick={() => void copyReport(latestResult)}>
|
||||
Copy report
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!latestResult ? (
|
||||
<div className="empty-state">
|
||||
<strong>Ready to test an API.</strong>
|
||||
<span>Enter a URL or run one of the probes after adding your endpoint.</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="result-summary">
|
||||
<span className={`big-status ${latestResult.status}`}>{latestResult.status}</span>
|
||||
<div>
|
||||
<strong>{latestResult.browserResult}</strong>
|
||||
<span>
|
||||
{latestResult.durationMs}ms
|
||||
{latestResult.statusCode ? `, HTTP ${latestResult.statusCode} ${latestResult.statusText ?? ''}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{latestResult.error ? <div className="alert danger">{latestResult.error}</div> : null}
|
||||
{latestResult.warnings.map((warning) => (
|
||||
<div className="alert warning" key={warning}>
|
||||
{warning}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="detail-grid">
|
||||
<div>
|
||||
<h3>Visible headers</h3>
|
||||
{latestResult.headers.length ? (
|
||||
<pre>{latestResult.headers.map(([name, value]) => `${name}: ${value}`).join('\n')}</pre>
|
||||
) : (
|
||||
<p className="muted">No response headers are visible to JavaScript.</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3>Body preview</h3>
|
||||
{latestResult.bodyPreview ? <pre>{latestResult.bodyPreview}</pre> : <p className="muted">No readable body.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="analysis-panel">
|
||||
<div className="section-heading compact">
|
||||
<div>
|
||||
<p className="eyebrow">Analysis</p>
|
||||
<h2>What it means</h2>
|
||||
</div>
|
||||
</div>
|
||||
{latestResult ? (
|
||||
<ul className="diagnostics-list">
|
||||
{latestResult.diagnostics.map((diagnostic) => (
|
||||
<li key={diagnostic}>{diagnostic}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="muted">Diagnostics appear after a run.</p>
|
||||
)}
|
||||
|
||||
<div className="history">
|
||||
<h3>Run history</h3>
|
||||
{results.length === 0 ? (
|
||||
<p className="muted">No history yet.</p>
|
||||
) : (
|
||||
results.map((result) => (
|
||||
<button className="history-row" key={result.id} onClick={() => setResults((current) => [result, ...current.filter((item) => item.id !== result.id)])}>
|
||||
<span className={`dot ${result.status}`} />
|
||||
<strong>{result.label}</strong>
|
||||
<span>{new Date(result.startedAt).toLocaleTimeString()}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
583
src/styles.css
Normal file
583
src/styles.css
Normal file
@@ -0,0 +1,583 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #172033;
|
||||
background: #eef4f8;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(37, 99, 235, 0.18), transparent 30rem),
|
||||
linear-gradient(135deg, #f8fbff 0%, #eef4f8 44%, #e8eef5 100%);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(1480px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 48px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 420px);
|
||||
gap: 24px;
|
||||
align-items: end;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: #2563eb;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 12px;
|
||||
color: #0f172a;
|
||||
font-size: clamp(2.8rem, 7vw, 6.5rem);
|
||||
line-height: 0.95;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 0;
|
||||
color: #111827;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 10px;
|
||||
color: #1f2937;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
max-width: 820px;
|
||||
margin-bottom: 0;
|
||||
color: #526173;
|
||||
font-size: 1.08rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.origin-panel {
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(37, 99, 235, 0.18);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
box-shadow: 0 18px 50px rgba(30, 41, 59, 0.08);
|
||||
}
|
||||
|
||||
.origin-panel span,
|
||||
.field span {
|
||||
display: block;
|
||||
margin-bottom: 7px;
|
||||
color: #64748b;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.origin-panel strong {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.dashboard,
|
||||
.results-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.45fr) minmax(360px, 0.8fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.results-layout {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.request-panel,
|
||||
.side-panel,
|
||||
.result-panel,
|
||||
.analysis-panel {
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.request-panel,
|
||||
.result-panel,
|
||||
.analysis-panel {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.section-heading,
|
||||
.section-subhead {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.section-heading.compact {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.section-subhead {
|
||||
margin: 18px 0 10px;
|
||||
}
|
||||
|
||||
.section-subhead h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.field-full {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #d4dce7;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #172033;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
background 160ms ease;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
height: 42px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 156px;
|
||||
padding: 12px;
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.14);
|
||||
}
|
||||
|
||||
.control-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.body-grid {
|
||||
grid-template-columns: minmax(180px, 0.32fr) minmax(0, 1fr);
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.field-body {
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.headers-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-row,
|
||||
.save-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 42px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.save-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.status-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
border: 1px solid #dbe3ef;
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.pill.ok {
|
||||
border-color: rgba(22, 163, 74, 0.25);
|
||||
background: #edfdf3;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.pill.warning {
|
||||
border-color: rgba(217, 119, 6, 0.28);
|
||||
background: #fff7ed;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.primary,
|
||||
.secondary,
|
||||
.ghost,
|
||||
.icon-button {
|
||||
min-height: 40px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 0 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 14px 30px rgba(37, 99, 235, 0.28);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
border: 1px solid #bfdbfe;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
border: 1px solid #d8e1ed;
|
||||
background: #ffffff;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 42px;
|
||||
padding: 0;
|
||||
border: 1px solid #d8e1ed;
|
||||
background: #ffffff;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.button-pair {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scenario-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.scenario-card {
|
||||
width: 100%;
|
||||
border: 1px solid #d9e2ee;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
background: #ffffff;
|
||||
color: #172033;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.scenario-card:hover,
|
||||
.saved-item:hover,
|
||||
.history-row:hover {
|
||||
border-color: #93c5fd;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.scenario-card strong,
|
||||
.scenario-card span,
|
||||
.saved-item strong,
|
||||
.saved-item span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.scenario-card span,
|
||||
.saved-item span,
|
||||
.history-row span,
|
||||
.muted {
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.saved-panel {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.saved-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.saved-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 42px;
|
||||
gap: 8px;
|
||||
border: 1px solid #d9e2ee;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.saved-item > button:first-child {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.result-summary {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #dbe3ef;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.result-summary strong,
|
||||
.result-summary span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.big-status {
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
color: #ffffff;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.big-status.passed,
|
||||
.dot.passed {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.big-status.failed,
|
||||
.dot.failed {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.big-status.opaque,
|
||||
.dot.opaque {
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin-bottom: 10px;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.alert.danger {
|
||||
border: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.alert.warning {
|
||||
border: 1px solid #fed7aa;
|
||||
background: #fff7ed;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
pre {
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
border: 1px solid #dbe3ef;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #0f172a;
|
||||
color: #dbeafe;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.diagnostics-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.diagnostics-list li {
|
||||
border: 1px solid #dbe3ef;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.history {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.history-row {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: 10px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
border: 1px solid #dbe3ef;
|
||||
border-radius: 8px;
|
||||
margin-top: 8px;
|
||||
padding: 10px;
|
||||
background: #ffffff;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: grid;
|
||||
min-height: 220px;
|
||||
place-content: center;
|
||||
border: 1px dashed #bfcbda;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.hero,
|
||||
.dashboard,
|
||||
.results-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.app-shell {
|
||||
width: min(100% - 20px, 1480px);
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.1rem;
|
||||
}
|
||||
|
||||
.request-panel,
|
||||
.side-panel,
|
||||
.result-panel,
|
||||
.analysis-panel {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.section-heading,
|
||||
.section-subhead,
|
||||
.result-summary {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.control-grid,
|
||||
.body-grid,
|
||||
.header-row,
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.history-row {
|
||||
grid-template-columns: 10px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.history-row span:last-child {
|
||||
grid-column: 2;
|
||||
}
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
5
vite.config.ts
Normal file
5
vite.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
});
|
||||
Reference in New Issue
Block a user