This commit is contained in:
Meghdad
2026-06-21 03:55:53 +03:30
commit 71a7ea1b57
16 changed files with 2866 additions and 0 deletions

48
.github/workflows/deploy.yml vendored Normal file
View 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
View File

@@ -0,0 +1,10 @@
node_modules
dist
*.tsbuildinfo
vite.config.d.ts
vite.config.js
.DS_Store
*.local
.env
.env.*
!.env.example

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

21
LICENSE Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View 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
View File

@@ -0,0 +1 @@

7
public/cors-lab.svg Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

20
tsconfig.json Normal file
View 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
View File

@@ -0,0 +1,5 @@
import { defineConfig } from 'vite';
export default defineConfig({
base: './',
});