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