feat: embed frontend into backend server with full-stack build pipeline (RCA-20)
The backend now serves the Vue admin UI as static files with SPA fallback, eliminating the need for a separate web server. Dockerfile builds both frontend and backend in a multi-stage pipeline. Added build:web and build:all scripts, updated CI to verify frontend builds, and fixed vitest config to exclude Playwright tests. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
@@ -24,9 +24,26 @@ jobs:
|
|||||||
- run: npm run typecheck
|
- run: npm run typecheck
|
||||||
- run: npm test
|
- run: npm test
|
||||||
|
|
||||||
|
web:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
|
|
||||||
|
- name: Install and build frontend
|
||||||
|
working-directory: web
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: test
|
needs: [test, web]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -38,6 +55,7 @@ jobs:
|
|||||||
docker run -d --name cb-test -p 8080:8080 cookiebridge:ci
|
docker run -d --name cb-test -p 8080:8080 cookiebridge:ci
|
||||||
sleep 3
|
sleep 3
|
||||||
curl -sf http://localhost:8080/health
|
curl -sf http://localhost:8080/health
|
||||||
|
curl -sf http://localhost:8080/ | grep -q '<div id="app">'
|
||||||
docker stop cb-test
|
docker stop cb-test
|
||||||
|
|
||||||
extension:
|
extension:
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@ web/node_modules/
|
|||||||
web/dist/
|
web/dist/
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
public/
|
||||||
data/
|
data/
|
||||||
*.db
|
*.db
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
|||||||
@@ -22,6 +22,23 @@ npm test # Run test suite
|
|||||||
npm run typecheck # Type checking only
|
npm run typecheck # Type checking only
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Admin panel (frontend):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
npm install
|
||||||
|
npm run dev # Starts Vite dev server on :5173 (proxies API to backend)
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend dev server proxies `/api`, `/admin`, `/ws`, and `/health` to `http://localhost:8100`. Start the backend first with `npm run dev` (or adjust the proxy target in `web/vite.config.ts`).
|
||||||
|
|
||||||
|
**Full production build:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:all # Builds backend (tsc) + frontend (vite) → copies to public/
|
||||||
|
npm start # Serves everything on :8080
|
||||||
|
```
|
||||||
|
|
||||||
**Extension:**
|
**Extension:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
15
Dockerfile
15
Dockerfile
@@ -1,3 +1,15 @@
|
|||||||
|
## Stage 1: Build frontend (Vue/Vite)
|
||||||
|
FROM node:22-alpine AS web-builder
|
||||||
|
|
||||||
|
WORKDIR /app/web
|
||||||
|
|
||||||
|
COPY web/package.json web/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY web/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
## Stage 2: Build backend (TypeScript)
|
||||||
FROM node:22-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -12,7 +24,7 @@ COPY tsconfig.json ./
|
|||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# --- Production image ---
|
## Stage 3: Production image
|
||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -25,6 +37,7 @@ RUN npm ci --omit=dev --ignore-scripts=false && \
|
|||||||
rm -rf /root/.npm /tmp/*
|
rm -rf /root/.npm /tmp/*
|
||||||
|
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=web-builder /app/web/dist ./public
|
||||||
|
|
||||||
ENV PORT=8080
|
ENV PORT=8080
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
|
|||||||
91
README.md
91
README.md
@@ -10,8 +10,10 @@ CookieBridge syncs browser cookies across devices through an encrypted relay ser
|
|||||||
- **Multi-browser support** — Chrome, Firefox, Edge, and Safari extensions.
|
- **Multi-browser support** — Chrome, Firefox, Edge, and Safari extensions.
|
||||||
- **Real-time sync** — WebSocket transport with HTTP polling fallback.
|
- **Real-time sync** — WebSocket transport with HTTP polling fallback.
|
||||||
- **Device pairing** — 6-digit code, 5-minute TTL, X25519 key exchange.
|
- **Device pairing** — 6-digit code, 5-minute TTL, X25519 key exchange.
|
||||||
|
- **Admin panel** — Built-in Vue 3 web UI for server management.
|
||||||
- **AI agent API** — Agents can retrieve encrypted cookies with granted access.
|
- **AI agent API** — Agents can retrieve encrypted cookies with granted access.
|
||||||
- **Conflict resolution** — Last-writer-wins with Lamport clocks.
|
- **Conflict resolution** — Last-writer-wins with Lamport clocks.
|
||||||
|
- **Database options** — In-memory (default), SQLite, or MySQL via setup wizard.
|
||||||
- **Self-hostable** — Docker image or run directly with Node.js.
|
- **Self-hostable** — Docker image or run directly with Node.js.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -22,7 +24,9 @@ CookieBridge syncs browser cookies across devices through an encrypted relay ser
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
The relay server starts on port 8080. Override with `PORT=3000 docker compose up -d`.
|
The server starts on port 8080 with the admin UI embedded. Override the port with `PORT=3000 docker compose up -d`.
|
||||||
|
|
||||||
|
Open `http://localhost:8080` to access the admin panel and run the setup wizard.
|
||||||
|
|
||||||
### Docker (manual)
|
### Docker (manual)
|
||||||
|
|
||||||
@@ -34,11 +38,30 @@ docker run -d -p 8080:8080 --name cookiebridge cookiebridge
|
|||||||
### From source
|
### From source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Install all dependencies
|
||||||
npm install
|
npm install
|
||||||
|
cd web && npm install && cd ..
|
||||||
|
|
||||||
|
# Build everything (backend + frontend)
|
||||||
|
npm run build:all
|
||||||
|
|
||||||
|
# Start the server (serves API + admin UI)
|
||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires Node.js 22+. The server listens on `0.0.0.0:8080` by default.
|
Requires Node.js 22+. The server listens on `0.0.0.0:8080` by default and serves the admin UI at the root URL.
|
||||||
|
|
||||||
|
### Development mode
|
||||||
|
|
||||||
|
Run the backend and frontend dev servers separately for hot-reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1: Backend (port 8080)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Terminal 2: Frontend dev server (port 5173, proxies API to backend)
|
||||||
|
cd web && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
@@ -77,15 +100,15 @@ Output goes to `extension/build/{browser}/`. Load the unpacked extension from th
|
|||||||
|
|
||||||
```
|
```
|
||||||
Browser Extension ──WebSocket/HTTP──▶ Relay Server (stores encrypted blobs)
|
Browser Extension ──WebSocket/HTTP──▶ Relay Server (stores encrypted blobs)
|
||||||
│ ▲
|
│ │
|
||||||
├── Ed25519 signing │
|
├── Ed25519 signing ├── Admin UI (Vue 3 SPA)
|
||||||
├── X25519 key exchange │
|
├── X25519 key exchange ├── SQLite / MySQL / In-memory
|
||||||
└── XChaCha20-Poly1305 encryption │
|
└── XChaCha20-Poly1305 encryption └── Setup wizard
|
||||||
│
|
│
|
||||||
AI Agent ──Bearer token──────────────────────┘
|
AI Agent ──Bearer token──────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
The relay server is a plain Node.js HTTP + WebSocket server with no framework dependencies. See [docs/architecture.md](docs/architecture.md) for the full design and [docs/security.md](docs/security.md) for the threat model.
|
The relay server is a plain Node.js HTTP + WebSocket server with no framework dependencies. In production, the server embeds the pre-built admin UI and serves it as static files. See [docs/architecture.md](docs/architecture.md) for the full design and [docs/security.md](docs/security.md) for the threat model.
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
@@ -104,6 +127,46 @@ The relay server is a plain Node.js HTTP + WebSocket server with no framework de
|
|||||||
| `GET` | `/health` | Health check |
|
| `GET` | `/health` | Health check |
|
||||||
| `WebSocket` | `/ws` | Real-time sync channel |
|
| `WebSocket` | `/ws` | Real-time sync channel |
|
||||||
|
|
||||||
|
### Admin Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `POST` | `/admin/setup/init` | Run setup wizard (set password, choose DB) |
|
||||||
|
| `GET` | `/admin/setup/status` | Check if setup has been completed |
|
||||||
|
| `POST` | `/admin/login` | Login to admin panel |
|
||||||
|
| `GET` | `/admin/devices` | List registered devices |
|
||||||
|
| `GET` | `/admin/connections` | List active WebSocket connections |
|
||||||
|
| `GET` | `/admin/agents` | List registered agents |
|
||||||
|
| `GET` | `/admin/stats` | Server statistics |
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Build Pipeline
|
||||||
|
|
||||||
|
The Dockerfile uses a multi-stage build:
|
||||||
|
|
||||||
|
1. **web-builder** — Installs frontend dependencies and runs `vite build`
|
||||||
|
2. **builder** — Compiles the TypeScript backend
|
||||||
|
3. **production** — Copies compiled backend + built frontend into a minimal image
|
||||||
|
|
||||||
|
The frontend is served from the `/public` directory inside the container. No separate web server (nginx, etc.) is needed.
|
||||||
|
|
||||||
|
### Database Persistence
|
||||||
|
|
||||||
|
By default, CookieBridge starts with in-memory storage. On first access, the setup wizard lets you choose:
|
||||||
|
|
||||||
|
- **In-memory** — No persistence, data resets on restart
|
||||||
|
- **SQLite** — File-based, mount a volume for persistence
|
||||||
|
- **MySQL** — Remote database, provide connection details
|
||||||
|
|
||||||
|
For SQLite persistence with Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d -p 8080:8080 -v cookiebridge-data:/app/data cookiebridge
|
||||||
|
```
|
||||||
|
|
||||||
|
Database configuration is stored in `data/db-config.json`.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -111,6 +174,7 @@ npm install
|
|||||||
npm run dev # Start with file watching
|
npm run dev # Start with file watching
|
||||||
npm test # Run test suite
|
npm test # Run test suite
|
||||||
npm run typecheck # Type checking only
|
npm run typecheck # Type checking only
|
||||||
|
npm run build:all # Build backend + frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
@@ -119,18 +183,21 @@ npm run typecheck # Type checking only
|
|||||||
src/
|
src/
|
||||||
cli.ts # Server entry point
|
cli.ts # Server entry point
|
||||||
relay/
|
relay/
|
||||||
server.ts # HTTP + WebSocket server
|
server.ts # HTTP + WebSocket server + static file serving
|
||||||
|
static.ts # Static file serving with SPA fallback
|
||||||
connections.ts # WebSocket connection manager
|
connections.ts # WebSocket connection manager
|
||||||
auth.ts # Token & challenge-response auth
|
auth.ts # Token & challenge-response auth
|
||||||
store.ts # In-memory encrypted cookie storage
|
admin/ # Admin panel API routes
|
||||||
tokens.ts # Device & agent registries
|
db/ # Database abstraction (memory, SQLite, MySQL)
|
||||||
crypto/ # XChaCha20-Poly1305, Ed25519
|
crypto/ # XChaCha20-Poly1305, Ed25519
|
||||||
pairing/ # Device pairing flow
|
pairing/ # Device pairing flow
|
||||||
sync/ # Sync engine, conflict resolution
|
sync/ # Sync engine, conflict resolution
|
||||||
protocol/
|
protocol/
|
||||||
spec.ts # Protocol types & constants
|
spec.ts # Protocol types & constants
|
||||||
|
web/ # Admin panel (Vue 3 + Vite)
|
||||||
extension/ # Multi-browser extension source
|
extension/ # Multi-browser extension source
|
||||||
tests/ # Vitest test suite
|
tests/ # Vitest test suite
|
||||||
|
docs/ # Architecture and security docs
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
"build:web": "cd web && npm run build && cd .. && rm -rf public && cp -r web/dist public",
|
||||||
|
"build:all": "npm run build && npm run build:web",
|
||||||
"start": "tsx src/cli.ts",
|
"start": "tsx src/cli.ts",
|
||||||
"dev": "tsx --watch src/cli.ts",
|
"dev": "tsx --watch src/cli.ts",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
|||||||
22
src/cli.ts
22
src/cli.ts
@@ -1,3 +1,5 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import fs from "node:fs";
|
||||||
import { RelayServer } from "./relay/index.js";
|
import { RelayServer } from "./relay/index.js";
|
||||||
import { loadDbConfig, createStores } from "./relay/db/index.js";
|
import { loadDbConfig, createStores } from "./relay/db/index.js";
|
||||||
|
|
||||||
@@ -16,10 +18,28 @@ async function main() {
|
|||||||
console.log("Configure a database during setup to enable persistent storage.");
|
console.log("Configure a database during setup to enable persistent storage.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = new RelayServer({ port, host, stores });
|
// Resolve frontend public directory: check ./public (production) then ../web/dist (development)
|
||||||
|
let publicDir: string | undefined;
|
||||||
|
const candidates = [
|
||||||
|
path.resolve("public"),
|
||||||
|
path.resolve(__dirname, "..", "web", "dist"),
|
||||||
|
];
|
||||||
|
for (const dir of candidates) {
|
||||||
|
if (fs.existsSync(path.join(dir, "index.html"))) {
|
||||||
|
publicDir = dir;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = new RelayServer({ port, host, stores, publicDir });
|
||||||
|
|
||||||
await server.start();
|
await server.start();
|
||||||
console.log(`CookieBridge relay server listening on ${host}:${port}`);
|
console.log(`CookieBridge relay server listening on ${host}:${port}`);
|
||||||
|
if (publicDir) {
|
||||||
|
console.log(`Serving admin UI from ${publicDir}`);
|
||||||
|
} else {
|
||||||
|
console.log("No frontend build found — admin UI not available. Run: npm run build:web");
|
||||||
|
}
|
||||||
|
|
||||||
process.on("SIGINT", async () => {
|
process.on("SIGINT", async () => {
|
||||||
console.log("\nShutting down...");
|
console.log("\nShutting down...");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { WebSocketServer, WebSocket } from "ws";
|
import { WebSocketServer, WebSocket } from "ws";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
|
import path from "node:path";
|
||||||
import { ConnectionManager } from "./connections.js";
|
import { ConnectionManager } from "./connections.js";
|
||||||
import { generateChallenge, verifyAuthResponse } from "./auth.js";
|
import { generateChallenge, verifyAuthResponse } from "./auth.js";
|
||||||
import { verify, buildSignablePayload } from "../crypto/signing.js";
|
import { verify, buildSignablePayload } from "../crypto/signing.js";
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
PING_INTERVAL_MS,
|
PING_INTERVAL_MS,
|
||||||
} from "../protocol/spec.js";
|
} from "../protocol/spec.js";
|
||||||
import { handleAdminRoute } from "./admin/routes.js";
|
import { handleAdminRoute } from "./admin/routes.js";
|
||||||
|
import { serveStatic } from "./static.js";
|
||||||
import type { DataStores, ICookieStore, IDeviceStore, IAgentStore, IAdminStore } from "./db/types.js";
|
import type { DataStores, ICookieStore, IDeviceStore, IAgentStore, IAdminStore } from "./db/types.js";
|
||||||
import { createMemoryStores } from "./db/memory.js";
|
import { createMemoryStores } from "./db/memory.js";
|
||||||
|
|
||||||
@@ -18,6 +20,8 @@ export interface RelayServerConfig {
|
|||||||
port: number;
|
port: number;
|
||||||
host?: string;
|
host?: string;
|
||||||
stores?: DataStores;
|
stores?: DataStores;
|
||||||
|
/** Directory containing pre-built frontend assets. Serves static files + SPA fallback. */
|
||||||
|
publicDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingAuth {
|
interface PendingAuth {
|
||||||
@@ -182,6 +186,11 @@ export class RelayServer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Serve frontend static files (if publicDir configured)
|
||||||
|
if (this.config.publicDir && method === "GET") {
|
||||||
|
if (serveStatic(req, res, this.config.publicDir)) return;
|
||||||
|
}
|
||||||
|
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
res.end("Not found");
|
res.end("Not found");
|
||||||
}
|
}
|
||||||
|
|||||||
78
src/relay/static.ts
Normal file
78
src/relay/static.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const MIME_TYPES: Record<string, string> = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".js": "application/javascript; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".woff": "font/woff",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
".ttf": "font/ttf",
|
||||||
|
".map": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve static files from `dir`. Returns true if a file was served,
|
||||||
|
* false if no matching file was found (caller should handle 404).
|
||||||
|
*
|
||||||
|
* For SPA support: if no file matches and `spaFallback` is true,
|
||||||
|
* serves index.html for requests that look like page navigations
|
||||||
|
* (no file extension).
|
||||||
|
*/
|
||||||
|
export function serveStatic(
|
||||||
|
req: http.IncomingMessage,
|
||||||
|
res: http.ServerResponse,
|
||||||
|
dir: string,
|
||||||
|
spaFallback = true,
|
||||||
|
): boolean {
|
||||||
|
const urlPath = (req.url ?? "/").split("?")[0];
|
||||||
|
const safePath = path.normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
|
||||||
|
let filePath = path.join(dir, safePath);
|
||||||
|
|
||||||
|
// Try exact file first
|
||||||
|
if (tryServeFile(res, filePath)) return true;
|
||||||
|
|
||||||
|
// Try index.html in directory
|
||||||
|
if (tryServeFile(res, path.join(filePath, "index.html"))) return true;
|
||||||
|
|
||||||
|
// SPA fallback: serve index.html for paths without a file extension
|
||||||
|
if (spaFallback && !path.extname(safePath)) {
|
||||||
|
if (tryServeFile(res, path.join(dir, "index.html"))) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryServeFile(res: http.ServerResponse, filePath: string): boolean {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
if (!stat.isFile()) return false;
|
||||||
|
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
||||||
|
|
||||||
|
// Cache hashed assets aggressively, everything else short-lived
|
||||||
|
const isHashed = filePath.includes("/assets/");
|
||||||
|
const cacheControl = isHashed
|
||||||
|
? "public, max-age=31536000, immutable"
|
||||||
|
: "public, max-age=60";
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Content-Length": stat.size,
|
||||||
|
"Cache-Control": cacheControl,
|
||||||
|
});
|
||||||
|
fs.createReadStream(filePath).pipe(res);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,5 +4,6 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
testTimeout: 15_000,
|
testTimeout: 15_000,
|
||||||
|
exclude: ["node_modules", "dist", "web", "extension"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user