Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
71aafb0
fix(sonarqube): resolve assertion and object stringification issues; …
ttbombadil Mar 26, 2026
75d658a
fix(parser): resolve ts2532 with indexed access instead of optional c…
ttbombadil Mar 27, 2026
410d29f
fix(parser): prefer .at() with non-null assertion for S7755 compliance
ttbombadil Mar 27, 2026
ab96ddb
fix: resolve 21 SonarQube S4325 non-null assertion issues
ttbombadil Mar 27, 2026
c9a9ed1
fix: reduce Cognitive Complexity in serial-monitor.tsx from 16 to 15
ttbombadil Mar 27, 2026
f68d935
fix: remove all S4325 non-null assertions and type cast issues
ttbombadil Mar 27, 2026
95f1bbd
fix: reduce Cognitive Complexity and fix SonarQube findings
ttbombadil Mar 27, 2026
a662c55
fix: remove S4325 non-null assertions in test files
ttbombadil Mar 27, 2026
b10edcd
fix: remove S4325 and S6306 violations - non-null assertions and stri…
ttbombadil Mar 27, 2026
020e355
fix: remove final S4325 violations - res.statusCode assertions
ttbombadil Mar 27, 2026
86aaac0
fix: truly remove S4325 violations in telemetry-integration.test.ts -…
ttbombadil Mar 27, 2026
26e1a51
fix: remove 5 remaining S4325 violations in telemetry-integration.tes…
ttbombadil Mar 27, 2026
b367da0
fix: graceful fallback when worker files not found
ttbombadil Mar 27, 2026
264eb82
fix: restore worker pool implementation from git history
ttbombadil Mar 27, 2026
c0a9991
fix: add null check for directCompiler in PooledCompiler
ttbombadil Mar 27, 2026
8ef8ddf
fix: resolve ts2367 promise status comparison errors in worker pool t…
ttbombadil Mar 27, 2026
1c42218
build: remove duplicate worker-pool-load-test.ts file
ttbombadil Mar 27, 2026
a98d8bb
fix: increase test timeout for sequential load test
ttbombadil Mar 27, 2026
35fecf3
fix: resolve all 7 SonarQube issues in compile-worker.ts
ttbombadil Mar 27, 2026
e99d832
test: boost coverage for worker subsystem and add SQ quality gate to …
ttbombadil Mar 28, 2026
57882e3
build: exclude worker entry point from coverage and add missing tests
ttbombadil Mar 28, 2026
35f6ec1
fix: generate coverage in pre-push hook for SonarQube
ttbombadil Mar 28, 2026
22c8362
docs: add usage section to README, fix flaky E2E timeout
ttbombadil Mar 28, 2026
730c73b
chore: add SonarLint configuration for local analysis
ttbombadil Mar 28, 2026
9232eb5
fix: resolve all SonarQube findings (token, undefined param, regex, t…
ttbombadil Mar 28, 2026
5aca5b7
fix(sonarqube): resolve 6 security and code quality issues
ttbombadil Mar 28, 2026
27d97d9
fix(sonarqube): resolve S4325, S6551, S5852 issues
ttbombadil Mar 30, 2026
9bd8440
fix(sonarqube): resolve remaining S4325, S6551, S5852 issues
ttbombadil Mar 30, 2026
53cc75a
fix(sonarqube): eliminate S5852 ReDoS vulnerability with stricter regex
ttbombadil Mar 30, 2026
3b13cc8
fix: preserve full compiler output on cache hits; resolve sonarqube i…
ttbombadil Apr 1, 2026
f9caa75
fix: restore TS-safe array access; expand exit code tolerance to null
ttbombadil Apr 1, 2026
b74e9b4
fix: extract lastPmCall variable to prevent formatter regression on .…
ttbombadil Apr 1, 2026
d6ce0a9
fix: suppress unicorn/prefer-at to prevent formatter regression on pm…
ttbombadil Apr 1, 2026
9ec329f
fix: recompile when binary cache lacks output sidecar to restore full…
ttbombadil Apr 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Thumbs.db
*~
*.log
npm-debug.log*
.zshrc.local
yarn-debug.log*
yarn-error.log*

Expand Down
6 changes: 3 additions & 3 deletions .husky/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

echo "🔍 Starte Qualitäts-Checks vor dem Push..."

# 1. Schnelle Tests ausführen
# 1. Schnelle Tests mit Coverage ausführen
# Hinweis: Falls Tests zu lange dauern, können Sie diese Hook mit:
# git push --no-verify
# umgehen (nicht empfohlen!)
echo "⏳ Starte npm run test:fast..."
npm run test:fast
echo "⏳ Starte npm run test:fast (mit Coverage)..."
LOG_LEVEL=warn npx vitest run --coverage --exclude tests/server/load-suite.test.ts --exclude tests/integration/serial-flow.test.ts
if [ $? -ne 0 ]; then
echo "❌ Fehler: Die schnellen Tests sind fehlgeschlagen!"
echo "💡 Tipp: Um den Hook zu überspringen, verwende: git push --no-verify"
Expand Down
4 changes: 4 additions & 0 deletions .sonarlint/connectedMode.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"sonarQubeUri": "http://localhost:9000",
"projectKey": "unowebsim"
}
12 changes: 12 additions & 0 deletions .sonarlintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Ignore shell configuration files containing environment secrets
.zshrc*
.bashrc*
.bash_profile*
.profile*

# Ignore editor config
.vscode/
.idea/

# Ignore node_modules
node_modules/
12 changes: 12 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"version": "0.2.0",
"configurations": [],
"compounds": [],
"inputs": [],
"env": {
"SONARQUBE_URL": "http://localhost:9000",
"SONAR_URL": "http://localhost:9000",
"SONAR_TOKEN": "squ_85eeef7888f45f1801a6c36fa7b94e1b7287f951",
"SONARQUBE_TOKEN": "squ_85eeef7888f45f1801a6c36fa7b94e1b7287f951"
}
}
102 changes: 78 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,84 @@ npm run dev:full

This will start both the backend server and the frontend development server.

## Usage

UnoSim can be run in several modes depending on your use case.

### Development Mode

```bash
npm run dev:full
```

Starts the backend (Express + WebSocket) and the Vite dev server with hot-reload.
The backend runs via `tsx` (TypeScript execution) and the client is served by Vite on a separate port with HMR.
Compilation uses `arduino-cli` directly on the host — Docker is **not** required.

| Component | Details |
|-----------|---------|
| Backend | `tsx server/index.ts` on port 3000 |
| Client | Vite HMR dev server (proxied) |
| Compiler | Direct `arduino-cli` calls on host |
| Worker Pool | Disabled (`PooledCompiler.usePool = false` outside production) |

### Production Mode

```bash
npm run build
npm run start
```

Builds the full stack (client + server + worker) into `dist/` and runs the production server.
The Vite-built client is served as static files from `dist/public/`.

| Component | Details |
|-----------|---------|
| Backend | `node dist/index.js` on port 3000 |
| Client | Static files from `dist/public/` |
| Compiler | Worker Pool with 4 parallel threads |
| Docker | Optional — enables sandboxed compilation if Docker Desktop is running |

> **Note:** Docker warnings at startup (`Cannot connect to the Docker daemon`) are non-blocking.
> The app falls back to direct `arduino-cli` compilation when Docker is unavailable.

### Docker Mode

```bash
docker build -t unowebsim:latest .
docker run --rm -p 3000:3000 -e NODE_ENV=production unowebsim:latest
```

Or with docker-compose:

```bash
docker-compose up --build
```

Runs the full application inside a container with `arduino-cli` pre-installed.
Available at `http://localhost:3000`.

### Available Scripts

| Command | Description |
|---------|-------------|
| `npm run dev:full` | Start backend + client in development mode |
| `npm run dev` | Start backend only (no client) |
| `npm run dev:client` | Start Vite client only |
| `npm run build` | Build client, server, and worker for production |
| `npm run start` | Run the production build |
| `npm run check` | TypeScript type-check (`tsc --noEmit`) |
| `npm run test:fast` | Run unit tests (excludes load tests) |
| `npm test` | Run all tests |
| `./run-tests.sh` | Full pipeline: lint, unit tests, Docker build, integration tests, E2E |

### Architecture Overview

- **Sandbox Runner Pool** — Manages a pool of sandbox processes that execute compiled Arduino binaries. Each simulation runs in an isolated child process with stdout/stderr capture for serial output and pin state reporting.
- **Compilation Worker Pool** — In production mode, 4 Node.js Worker Threads handle compilations in parallel via the `PooledCompiler`. Each worker runs `arduino-cli` and caches build artifacts (hex files, core objects) for faster recompilation.
- **WebSocket Layer** — Real-time communication between client and server for serial output, pin state batches, and simulation control (start/stop/pause/resume).
- **SonarQube Integration** — Quality gate checks are built into the pre-push hook and the test pipeline (`./run-tests.sh`). Coverage reports are generated automatically.

## Notes for running tests (optional)

The repository contains a **robust, fast test pipeline**:
Expand Down Expand Up @@ -131,27 +209,3 @@ The backend utilizes an Adapter Pattern for compilation:
- Worker Isolation: Each compilation task runs in a separate thread, reducing API latency by ~30% under concurrent load.

- Graceful Shutdown: Intelligent SIGTERM handling ensures all worker threads and file handles are closed properly.

## Docker

This repository includes a Dockerfile that builds the project using Node.js v25.2.1.

- Build the image:

```bash
docker build -t unowebsim:latest .
```

- Run the container (exposes port 3000):

```bash
docker run --rm -p 3000:3000 -e NODE_ENV=production unowebsim:latest
```

- Alternatively use docker-compose:

```bash
docker-compose up --build
```

The server will be available at http://localhost:3000
3 changes: 2 additions & 1 deletion client/src/components/features/code-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -582,10 +582,11 @@ export function CodeEditor({
// Move cursor to end of pasted text
const lines = text.split("\n");
const endLineNumber = selection.startLineNumber + lines.length - 1;
const lastLineText = lines.at(-1) ?? "";
const endColumn =
lines.length === 1
? selection.startColumn + text.length
: lines.at(-1)!.length + 1;
: lastLineText.length + 1;

editor.setPosition({
lineNumber: endLineNumber,
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/features/parser-output.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ export function ParserOutput({
size="sm"
onClick={() => {
onInsertSuggestion?.(
message.suggestion!,
message.suggestion ?? "",
message.line,
);
}}
Expand Down
134 changes: 78 additions & 56 deletions client/src/components/features/serial-monitor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,30 @@ function processAnsiCodes(text: string): string {
return processed;
}

/**
* Strips leading backspace characters from text and removes corresponding
* characters from the last incomplete line.
*/
function consumeLeadingBackspaces(
lines: Array<{ text: string; incomplete: boolean }>,
text: string,
): string {
let idx = 0;
while (idx < text.length && text[idx] === "\b") {
idx++;
}
if (idx === 0) return text;

const lastLine = lines.at(-1);
if (!lastLine?.incomplete) return text;

lastLine.text = lastLine.text.slice(
0,
Math.max(0, lastLine.text.length - idx),
);
return text.slice(idx);
}

// Exported for unit testing and reuse inside the hook
export function applyBackspaceAcrossLines(
lines: Array<{ text: string; incomplete: boolean }>,
Expand All @@ -79,36 +103,20 @@ export function applyBackspaceAcrossLines(
): string | null {
// Handle backspaces at the start of text
if (text.includes("\b")) {
// Count leading backspaces to remove from previous line
let backspaceCount = 0;
let idx = 0;
while (idx < text.length && text[idx] === "\b") {
backspaceCount++;
idx++;
}

if (
backspaceCount > 0 &&
lines.length > 0 &&
lines.at(-1)!.incomplete
) {
const lastLine = lines.at(-1)!;
lastLine.text = lastLine.text.slice(
0,
Math.max(0, lastLine.text.length - backspaceCount),
);
text = text.slice(backspaceCount);
}
text = consumeLeadingBackspaces(lines, text);
}

// If there's still text to process and we have an incomplete line, append to it
if (text && lines.length > 0 && lines.at(-1)!.incomplete) {
const cleanText = processAnsiCodes(text);
if (cleanText) {
lines.at(-1)!.text += cleanText;
lines.at(-1)!.incomplete = !isComplete;
if (text) {
const lastLine = lines.at(-1);
if (lastLine?.incomplete) {
const cleanText = processAnsiCodes(text);
if (cleanText) {
lastLine.text += cleanText;
lastLine.incomplete = !isComplete;
}
return null; // already handled
}
return null; // already handled
}

// No text left after backspace processing, or no incomplete line to append to
Expand Down Expand Up @@ -140,17 +148,49 @@ function processCarriageReturnLine(
const parts = text.split("\r");
const cleanParts = parts.map((p) => processAnsiCodes(p));
if (cleanParts.length <= 1) return false;
const finalText = cleanParts.at(-1)!;
if (lines.length > 0 && !lines.at(-1)!.incomplete) {
lines.push({ text: finalText, incomplete: !lineComplete });
} else if (lines.length > 0) {
const finalText = cleanParts.at(-1) ?? "";
const lastLine = lines.at(-1);
if (lastLine?.incomplete) {
lines[lines.length - 1] = { text: finalText, incomplete: !lineComplete };
} else {
lines.push({ text: finalText, incomplete: !lineComplete });
}
return true;
}

function processLineWithControls(
lines: ProcessedLine[],
text: string,
controls: ReturnType<typeof hasControlChars>,
shouldClear: boolean,
lineComplete: boolean,
): { text: string; shouldClear: boolean; handled: boolean } {
let newShouldClear = shouldClear;
let newText = text;

if (controls.hasClearScreen) {
newShouldClear = true;
lines.length = 0;
}

if (controls.hasCursorHome && newShouldClear) {
lines.length = 0;
newShouldClear = false;
}

const backspaceResult = applyBackspaceAcrossLines(lines, newText, lineComplete);
if (backspaceResult === null) {
return { text: "", shouldClear: newShouldClear, handled: true };
}

newText = backspaceResult;
if (controls.hasCarriageReturn && processCarriageReturnLine(lines, newText, lineComplete)) {
return { text: "", shouldClear: newShouldClear, handled: true };
}

return { text: newText, shouldClear: newShouldClear, handled: false };
}

export function SerialMonitor({
output,
isConnected: _isConnected,
Expand Down Expand Up @@ -204,39 +244,21 @@ export function SerialMonitor({
let text = line.text;
const controls = hasControlChars(text);

if (controls.hasClearScreen) {
shouldClear = true;
lines.length = 0;
}

if (controls.hasCursorHome) {
if (shouldClear) {
lines.length = 0;
shouldClear = false;
}
}

// Handle backspace across line boundaries: apply to last incomplete line
const backspaceResult = applyBackspaceAcrossLines(
const { text: processedText, shouldClear: newShouldClear, handled } = processLineWithControls(
lines,
text,
controls,
shouldClear,
line.complete ?? true,
);
if (backspaceResult === null) {
return; // handled fully
}
text = backspaceResult;
shouldClear = newShouldClear;

if (controls.hasCarriageReturn) {
if (processCarriageReturnLine(lines, text, line.complete ?? true)) {
return;
if (!handled && processedText) {
const cleanText = processAnsiCodes(processedText);
if (cleanText) {
lines.push({ text: cleanText, incomplete: !line.complete });
}
}

const cleanText = processAnsiCodes(text);
if (cleanText) {
lines.push({ text: cleanText, incomplete: !line.complete });
}
});

return lines;
Expand Down
4 changes: 2 additions & 2 deletions client/src/hooks/use-compile-and-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ export function useCompileAndRun(params: CompileAndRunParams): UseCompileAndRunR
},
onError: (err: unknown) => {
const backendDown = params.isBackendUnreachableError(err);
const message = err instanceof Error ? err.message : String(err);
const message = err instanceof Error ? err.message : JSON.stringify(err, null, 2);
params.toast({
title: backendDown ? "Backend unreachable" : "Upload failed",
description: backendDown
Expand Down Expand Up @@ -498,7 +498,7 @@ export function useCompileAndRun(params: CompileAndRunParams): UseCompileAndRunR
} catch { }
},
onError: (error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
const message = error instanceof Error ? error.message : JSON.stringify(error, null, 2);
params.toast({
title: "Start Failed",
description: message || "Could not start simulation",
Expand Down
4 changes: 2 additions & 2 deletions client/src/hooks/useSimulatorFileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ export function useSimulatorFileSystem({
setTabs(newTabs);

if (activeTabId === tabId) {
if (newTabs.length > 0) {
const newActiveTab = newTabs.at(-1)!;
const newActiveTab = newTabs.at(-1);
if (newActiveTab) {
setActiveTabId(newActiveTab.id);
setCode(newActiveTab.content);
} else {
Expand Down
Loading
Loading