When a sketch's external library headers were only referenced from
project headers (e.g. esp32-eyes.ino includes Common.h, Common.h
includes <ESP32Servo.h>), the compile failed with
fatal error: ESP32Servo.h: No such file or directory
because _detect_external_includes was only called on main_content
(the processed .ino). Project .h/.cpp files were never scanned, so
ESP32Servo / DHT / Adafruit_Sensor referenced only transitively
through user code never reached _resolve_library_components and
never landed in user_libs_all/.
Fix: collect ext_headers from main_content PLUS every uploaded
.h/.hpp/.ino/.c/.cpp file before resolving libraries. Lib resolver
already walks transitive includes inside the lib bundle once it's
copied; this just makes sure the first-level set covers user
project headers too.
Repro: open https://velxio.dev/example/robot-desktop-eyes, click
Compile. Before this commit: 13 errors starting at ESP32Servo.h.
After: ext_headers includes ESP32Servo.h on the first pass and the
build proceeds.
Sketch fails to compile out of the box with
fatal error: ESP32Servo.h: No such file or directory
because the ESP32Servo / U8g2 / DHT / Adafruit Unified Sensor libs
aren't part of arduino-esp32 and weren't declared on the example.
loadExample.ts already iterates `example.libraries` and runs
arduino-cli lib install for any missing entry before the user
touches Compile. Adding the four real deps the sketch needs gets
the example compiling cleanly on a fresh container without any
manual Library Manager dance.
When the auto-compile path in handleRun() finishes without producing a
compiledProgram, the previous code dropped the failure on the floor with
only a `console.warn` — the user clicked Run, nothing happened, and they
had no idea why. The accompanying comment also promised "always start
even if compiledProgram is empty" but the code did the opposite.
This commit replaces the dead comment + silent warn with a top-level
error toast + addLog entry, with a different copy for MicroPython mode
(suggests "click Load MicroPython to retry") vs Arduino C++ mode
(directs the user to the output console for the underlying error).
handleCompile already writes the actual cause to the compile-output
console via addLog — this fix just makes sure the user knows their
click failed and where to look.
Three small fixes to frontend/public/manifest.webmanifest so the
Bubblewrap-generated TWA (and Add-to-Home-Screen PWA installs) feel
right on a phone:
- orientation: landscape → any. Landscape-forced on a phone
locks the device in side-grip whenever Velxio is foregrounded;
the editor + simulator work fine in portrait too (the file
explorer collapses gracefully). Tablets and desktops still
default to landscape because they're naturally wider, so this
only changes behaviour where the lock would actively hurt.
- name: "Arduino Emulator" → "Circuit & Arduino Simulator".
Matches the title tag + Open Graph copy that velxio.dev uses
everywhere else and reflects the SPICE / ESP32 / RP2040 work
the project has grown into since the original name was written.
- description: was 'Free local Arduino emulator … No cloud, no
latency.' That was true for the OSS self-host but misleading
for an installed PWA that talks to velxio.dev. Rewritten to
describe the actual product surface (the boards, the SPICE
sim, "free and open source") without making a claim the live
site can't keep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Snapshot was written when the example had components: [] and wires:
[]. Now that 5bd541e populated the circuit (OLED + 2 buttons) and
2dea3f0 renamed the OLED pins to match wokwi-ssd1306's real
pinInfo, the netlist contains the button pull-down resistors and
floating-net autopulls. Regenerated with `vitest run -u`.
robot-desktop-eyes and the day-30/100-days OLED example wired the
SSD1306 OLED with SDA/SCL/VCC, but the wokwi-ssd1306 element
exposes pinInfo as DATA/CLK/VIN/GND. The mismatched names couldn't
resolve, so all three wire endpoints fell back to (0,0) of the
component and visually attached to the corner instead of the pins.
Same class of bug on the wokwi-big-sound-sensor in
robot-desktop-eyes: the element has AOUT/DOUT (no plain OUT). The
sketch uses digitalRead(SOUND_PIN), so route to DOUT.
The COMPONENT_PIN_ALIASES map in wokwiZip.ts only normalises on
.zip import — static examples have to use the real pinInfo names.
The 100d-esp32-oled-smart-ui-eyes-animation-time-and-weather-micropython
example had components: [] and wires: [] — the MicroPython code wired
an SSD1306 OLED on I2C (GPIO 21/22) plus two buttons (GPIO 14, 27) but
the circuit had nothing on the canvas, so users saw a bare ESP32 board
and the simulation was missing every peripheral the code drives.
Adds:
- wokwi-ssd1306 on I2C (3V3 / GND / SDA=21 / SCL=22)
- two wokwi-pushbuttons wired HIGH-when-pressed (3V3 → 1.l, 2.l → GPIO
14 / 27) to match the `if pin.value(): pressed` check in main.py
Pulls https://github.com/davidmonterocrespo24/robot_desktop into the
examples gallery as a real-world ESP32 + sensors project. Cozmo-style
desktop robot: SSD1306 OLED face that blinks, looks around, and shows
emotions; DHT11 weather mode triggered after 10 min idle; PIR wakeup
from sleep; LDR-driven sleep when the room goes dark; sound-triggered
reactions; and two eyebrow servos.
Ships as 34 separate files (one .ino + 33 headers / source) rather
than the usual single-sketch flatten. The face engine
(Eye / EyeTransition / EyeVariation / FaceBehavior / FaceExpression
/ FaceEmotions / BlinkAssistant / LookAssistant / …) splits
responsibility across enough classes that flattening would obscure
the design. Velxio's multi-file `files: [{ name, content }]`
mechanism handles this cleanly — the editor mounts the .ino as the
active sketch and the rest sit in the same workspace.
Pre-placed components match the original board's pin map verbatim
from Common.h:
- SSD1306 OLED on I²C (SDA=21, SCL=22 — ESP32 default)
- DHT11 on GPIO 15
- PIR motion on GPIO 4
- Big sound sensor on GPIO 2
- Photoresistor on GPIO 34 (ADC1)
- Right eyebrow servo on GPIO 12
- Left eyebrow servo on GPIO 13
Arduino libraries (U8g2lib, DHT, ESP32Servo, Adafruit_Sensor) are
auto-installed by velxio's Library Manager on the first compile.
Category 'displays', difficulty 'advanced', tags cover both the
sensor list and the project's identity (cozmo / robot / animation /
eyes) so the gallery search surfaces it from multiple angles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The BoardKind type and the QEMU backend already supported
raspberry-pi-4 (Cortex-A72) and raspberry-pi-5 (Cortex-A76) by reusing
the Pi 3 arm64 image set, but the frontend had no way to actually
select either: the board picker, the canvas renderer, the serial
monitor, the oscilloscope channel list, and the editor toolbar all
hard-coded "raspberry-pi-3" as the only Pi entry. ComponentRegistry
even registered Pi 4 / Pi 5 metadata pointing at the velxio-raspberry-pi-3
custom-element tag — a placeholder that meant both boards rendered as
a Pi 3 in the picker thumbnail and on the canvas.
Add dedicated boards top-to-bottom:
* `RaspberryPi4Element.ts` / `RaspberryPi5Element.ts` — Velxio-style
schematic SVG (authored from scratch, not traced). Pi 4 is the
green PCB with BCM2711 SoC, 4× USB-A, USB-C power, dual µHDMI;
Pi 5 is the darker green PCB with BCM2712 + RP1 southbridge,
2.5 GbE, USB-C 5V/5A, PCIe FFC connector, dedicated power
button. Both carry a small "velxio" mark in the corner.
* `pi40PinHeader.ts` — shared `buildPi40PinHeader()` helper that
returns the 40-pin BCM layout. Every Pi from the 1B+ onwards
uses the same physical pin positions and same BCM GPIO
assignment, so Pi 3 / Pi 4 / Pi 5 elements all consume this
helper and example wires drawn against one model transfer to
the others without re-routing.
* React wrappers `RaspberryPi4.tsx` / `RaspberryPi5.tsx` render the
custom elements at absolute positions (mirrors how
RaspberryPi3.tsx handles the Pi 3 illustration).
* Wire-up across the editor surface:
- BoardOnCanvas: BOARD_SIZE entry + switch case.
- BoardPickerModal: description, icon, kinds list.
- ComponentPickerModal: thumbnails now instantiate the dedicated
custom element (was velxio-raspberry-pi-3 fallback).
- SerialMonitor / EditorToolbar: pill labels, icons, colours.
- Oscilloscope: GPIO channel list (28 BCM pins).
- SimulatorCanvas: remote-boards filter for run/stop sync.
- SPICE boardPinGroups: same 5V / 3V3 / GND as Pi 3.
- boardPinToNumber: accepts physical pin numbers ("1"-"40"),
BCM names ("GPIO14") and power labels for any Pi 3/4/5 id.
- ComponentRegistry: dedicated tagNames + per-board thumbnails
(green for Pi 4, darker green for Pi 5).
* EditorToolbar's Pi 3 special cases (Linux/Python compile path,
Run/Stop routing) now use `isPiBoardKind()` so Pi 4 and Pi 5
inherit the same behaviour automatically, and any future Pi
family member (Zero / 1 / 2) lands in the right code paths the
moment its backend boots.
QEMU backend was already wired (qemu_manager.py:71/82 + manifest entry
'raspberry-pi-3-virt' shared across arm64 Pis), so this commit makes
both boards selectable end-to-end without any backend follow-up.
Three QoL fixes for the Tauri shell:
1. Hide the entire AppHeader strip in VITE_DESKTOP, not just the
marketing nav. The previous gate left the black bar painting
over the editor with the brand + auto-save + share + auth
slot, all of which are irrelevant in desktop (cloud Pro
features, license is handled by DesktopWelcomePage, the title
bar already says "Velxio Desktop"). Return null at the top so
the editor takes the full window height.
2. Splash screen during sidecar boot + Monaco hydration. Cold
launch was a 3-8 s black window — now there's an inline SVG
logo, "Velxio" wordmark, slogan, animated spinner, and a
"Starting local backend…" caption. Lives in index.html as a
fixed-position overlay with display:none by default; the inline
script reveals it only when `window.__TAURI__` is present, so
web users never see it. main.tsx fades it out (250 ms ease-out)
after two animation frames — guarantees React's first paint has
committed before the handoff, no black flash. Self-contained:
inline styles, inline SVG, inline CSS keyframes, zero external
requests.
3. Native locale switcher under View → Language. Emits
`velxio://menu` with action='set-locale' + the locale code; the
desktop/menu.ts handler navigates via history.pushState +
popstate so React Router picks it up without a hard reload
(Monaco + simulator state preserved). Locale list mirrors
i18n/config.ts::LOCALES.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two intertwined bugs were leaving every ESP32 ePaper example broken
end-to-end. Only the 5.65" UC8159c panel surfaced the failure
audibly ("Busy Timeout!" repeating in serial), because its inverted
busy polarity caused the firmware to hang inside `_waitBusy()`. The
SSD168x ePaper examples APPEARED to run cleanly but never actually
rendered anything to the panel — the canvas stayed at the idle paper
colour because the same registration path was broken.
Root cause #1 — `setSensors` was a full REPLACE, not a merge.
`Esp32Bridge.setSensors(sensors)` did `this._pendingSensors =
sensors`. At `startBoard()` time the store iterates components,
resolves wires for any entry in `SENSOR_COMPONENT_MAP` (DHT22 /
HC-SR04 / I²C sensors) and calls `setSensors(...)` with that list.
ePaper components live in `PartSimulationRegistry` (not in the
sensor map) and are registered via `sendSensorAttach()` AT
COMPONENT-MOUNT TIME — well before `startBoard()` runs. Full-replace
semantics blew that registration away on every Run click, so the
worker never instantiated an `Ssd168xEpaperSlave` / `Uc8159cEpaperSlave`,
no SPI bytes were decoded, no frames were latched, and BUSY was
never driven.
Fix: upsert by `pin` so pre-existing registrations from
PartSimulationRegistry handlers are preserved alongside the
startBoard-resolved sensors. Confirmed via a WebSocket spy that the
`start_esp32` payload now carries the ePaper sensor entry.
Root cause #2 — BUSY polarity was hard-coded for SSD168x only.
Verified against upstream GxEPD2 source:
* SSD168x family — constructor passes `_busy_level = HIGH`
→ BUSY=HIGH means busy, LOW means ready.
* UC8159c family — constructor passes `_busy_level = LOW`
→ BUSY=LOW means busy, HIGH means ready.
The worker only drove BUSY after a frame flush (and at the wrong
polarity for UC8159c), so the firmware's first `_waitBusy()` inside
`_PowerOn()` / `_InitDisplay()` — which fires BEFORE any frame —
blocked for the full 25 s `_busy_timeout`.
Fix: read `controller_family` from the registration payload, pick the
per-family idle level, and (a) seed the pin to IDLE at registration so
the first `_waitBusy()` sees "ready" immediately, (b) use that
polarity (idle vs. busy) when pulsing on frame flush.
Verified on https://velxio.dev/example/epaper-5in65-7c-esp32-rainbow:
the serial timeline now reads `_InitDisplay reset : 1566` /
`_PowerOn : 148` / `_PowerOff : 183` / `frame done` (all sub-2 ms
busy-waits, no timeouts). Sensor registration confirmed via the
`start_esp32` payload carrying the `epaper-ssd168x` entry.
Two new modules under the existing desktop/ subtree, both no-op
outside a Tauri runtime (tauriBridge.listen / .invoke fail gracefully).
desktop/menu.ts — listens for the `velxio://menu` event the Rust
shell emits from the native menubar (Velxio Desktop / File / Edit
/ View / Help, full menu defined in
pro/desktop/src-tauri/src/menu.rs). Internal actions handled
directly here: Save .vlx and Open .vlx via utils/vlxFile, Toggle
Serial Monitor via useSimulatorStore, Check for Updates via the
tauri-plugin-updater global. The rest (new-project, Find,
Toggle File Explorer) re-emit as window CustomEvent so the owners
of that UI state can subscribe without pulling this module in.
desktop/log.ts — `dlog(message, extra?)` round-trips a line to a
Rust `write_debug_log` command that appends to
`<app_data_dir>/desktop-debug.log`. Packaged Tauri apps have no
devtools or stdout capture, so this is the only way to see what
the webview did when a user reports a bug. Falls back to plain
console.log when the command isn't registered (older shell).
mountDesktop() now installs the menu listener and dlog's its own
start — useful as a "did the desktop module even load" smoke marker.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The marketing nav (Home/Docs/Examples/Pricing/Blog/GitHub/Discord) and
the LandingPage hero are great for velxio.dev visitors but become
clutter once the SPA ships inside a Tauri shell — the user installed
the desktop app to land in the editor, not to read about the project.
Two small VITE_DESKTOP gates handle this:
- AppHeader.tsx hides the <nav> + the mobile hamburger that toggles
it. The brand, language switcher, auto-save indicator, share
button, and the pro overlay's auth slot all stay visible — they
carry real per-session info, not navigation.
- App.tsx swaps the `/` route's element for a <Navigate to=/editor>
so first-launch (and any future `velxio://` deep-link that lands
on `/`) goes straight to the editor.
Equivalent actions for the items being hidden live on the native
menubar that the velxio-prod overlay builds via
pro/desktop/src-tauri/src/menu.rs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Real digital storage scopes have a trigger that pins the visible window
around a detected edge — without it, sparse activity (UART bytes once
per loop, an interrupt firing every few seconds) scrolls off the screen
faster than the eye can catch. Velxio's scope was free-running only,
which made the recent UART TX waveform work effectively invisible at
fine time/div settings: the byte burst was 87 µs but the window only
showed the most recent 1 ms.
Three trigger modes, matching what you'd find on a Rigol / Tektronix:
* Auto — current free-running behaviour, window's right edge
tracks the most recent sample. Default.
* Normal — window pins around each triggering edge so the event
lands at `triggerPosition * windowMs` from the left
(default centred at 0.5). Keeps re-pinning on every
new triggering edge.
* Single — arms once, freezes the trace on the first triggering
edge by flipping `running = false`. User clicks
"Re-arm" to capture again.
Three knobs configurable per mode:
- source: which channel produces the trigger event
- edge: rising (↑) / falling (↓) / either (⇅)
- position: trigger lands at this fraction of the window
(UI hard-codes centre 0.5 for now; the store field
accepts any value if we want a draggable handle later)
UI additions in the scope header (only shown when mode != auto):
- source / edge dropdowns
- status badge (Armed / Triggered / Captured) with pulse animation
on Armed so the user knows the scope is waiting for an event
- Re-arm button in Single mode after capture
Canvas changes:
- Dashed orange "T" marker drawn at the trigger position when an
edge is latched and within the visible window.
Store changes:
- pushSample peeks at the trigger channel's previous state, detects
a matching edge, sets triggeredAtMs (and stops `running` for
Single mode). matchesTriggerEdge() exported for unit testing.
- clearSamples / setTriggerMode / setTriggerChannel / setTriggerEdge
all re-arm the trigger; rearmTrigger() explicitly resets and resumes
capture (used by the Re-arm button after a single-shot).
Covered by 11 new vitest cases (oscilloscope-trigger.test.ts) plus the
existing 1892 tests still pass.
Closes the "I set 0.1 ms/div on a Serial.print sketch and see a flat
line" UX trap reported on the Discord follow-up — at 0.1 ms/div the
window is 1 ms but bytes fire every 2 s, so without a trigger the
chance of catching the burst is < 0.05 %. With Normal trigger on
rising D1 the burst pins in the middle of the window and the user can
zoom down to bit level (8.68 µs each) without losing it.
Velxio had two parallel import paths that confused users (reported on
Discord by AgUn / dmontero):
* Toolbar "Import a project from a .zip file" → Wokwi .zip only
* File-explorer "Open .vlx file" → Velxio .vlx only
If you exported a Velxio project as .vlx and tried to bring it back via
the toolbar Import button, you bounced off "wrong format" with no hint
that the .vlx loader was hiding behind the file-explorer save-bar.
Fix: introduce `utils/importProject.ts` as the single dispatcher. It
sniffs the extension and routes:
*.vlx → importVlxFile (writes directly to stores)
*.zip → importFromWokwiZip (returns a payload the caller applies,
so the toolbar can still trigger the
install-libraries modal afterwards)
Both UI entry points now go through the dispatcher with the same
`accept=".vlx,.zip,application/json,application/zip"` filter:
* Toolbar "Import project (.vlx Velxio or .zip Wokwi)"
* File-explorer "Open project (.vlx Velxio or .zip Wokwi)"
The toolbar tooltip is i18n-driven — updated EN + 8 other locales
(es, fr, de, it, pt-br, ja, ru, zh-cn) so every user sees the same
clarification.
Wokwi compatibility kept intact — the .zip path still resolves to
`importFromWokwiZip` and the same library-install modal pops if the
imported project lists libraries we don't have locally.
Closes the same gap as the AVR / RP2040 commits — qemu-lcgamboa's UART
transmits the byte over the WebSocket as a 'serial_output' event with no
GPIO toggle, so an oscilloscope on the ESP32 TX pin saw nothing while
real silicon would render the 8N1 frame at the configured baud rate.
Two changes inside Esp32Bridge:
* New `onPinChangeWithTime: (pin, state, timeMs) => void` callback
that hooks the oscilloscope at parity with AVRSimulator /
RP2040Simulator. The 'gpio_change' event now also flows through it
(timestamped with `performance.now()` — QEMU virtual time isn't
surfaced across the wire, but at 1× sim speed the wall-clock skew
is invisible on any practical sweep). This also fixes the broader
issue that ESP32 boards previously couldn't show ANY digital GPIO
activity on the scope.
* `emitUartTxFrame(byte, uart)` synthesizes start + 8 data LSB-first
+ stop transitions at `this.uartBaudRate` (default 115200) on the
UART0 TX pin, mapped per board variant:
esp32 / esp32-devkit-c-v4 / esp32-cam / wemos-lolin32-lite: GPIO1
esp32-s3 / xiao-esp32-s3 / arduino-nano-esp32: GPIO43
esp32-c3 / xiao-esp32-c3 / aitewinrobot-esp32c3-supermini: GPIO21
Backend doesn't expose the live baud rate so we default to 115200
(the Arduino default). Override path: bridge.uartBaudRate = N
once we surface Serial.begin's argument via a backend event.
Wire-up: `bridge.onPinChangeWithTime = getOscilloscopeCallback(boardId)`
inside the three Esp32Bridge construction sites in useSimulatorStore
(setBoardType, addBoard, changeBoard).
Same gap as the AVR USART: rp2040js's UART fires `onByte(value)` per
transmitted byte but never toggles the corresponding GPIO, so an
oscilloscope on GP0 (UART0 TX, default for Arduino-Pico's Serial1) sees
nothing during `Serial.print`. Real silicon drives the pin with the
full UART frame at the configured baud rate, and Velxio should match.
`emitUartTxFrame(uartIdx, byte)` derives:
* `txPin` via FUNCSEL inspection: walk GP0 / GP12 / GP16 / GP28 (the
four candidates for UART0 TX per RP2040 datasheet) and pick the
first whose `functionSelect == 2` (FUNCTION_UART). Same for UART1.
Fall back to GP0 / GP4 when nothing is mapped (firmware hasn't
called `Serial1.begin()` properly).
* `baudRate` and `bitsPerChar` directly from the UART peripheral
(rp2040js already exposes these as live getters).
* Time from the RP2040 IClock's `nanos` counter, matching the
existing `setupGpioListeners` path — UART waveforms therefore stack
consistently with PIO / SIO traces on the same scope.
Both `uart[0].onByte` and `uart[1].onByte` get hooked. The seed-idle-
HIGH baseline is pushed once per UART per simulation run; `stop()`
clears the flag so a re-run gets a fresh seed (matching how the scope
buffer is cleared on restart).
avr8js intercepts the transmitted byte at the UDR0 register and never
toggles the corresponding GPIO. Real ATmega328P / ATmega2560 hardware
drives PD1 / PE1 with a start bit, 8 data bits LSB-first, and a stop bit
at the configured baud rate the moment TXEN is set. An oscilloscope
probe on D1 therefore showed nothing in Velxio while the same probe in
the real world would resolve the UART frame.
Synthesize the frame from the inside of `onByteTransmit`:
* Read `usart.baudRate`, `usart.bitsPerChar`, `usart.parityEnabled`,
`usart.parityOdd`, `usart.stopBits` so unusual configurations stay
accurate (avr8js already exposes these as public getters).
* Build the bit list start + data(LSB first) + parity? + stopBit(s).
* For each transition vs. previous state (initial = idle HIGH), call
`onPinChangeWithTime(1, state, timeMs)` where
`timeMs = (cpu.cycles + i * cyclesPerBit) / 16_000`. Same
simulator-time clock the existing port-listener path uses, so the
scope draws the UART waveform cycle-accurately alongside other GPIO
activity.
Also hook `onConfigurationChange` to detect TXEN flipping 0→1 and seed
the scope baseline at idle HIGH; without that, the very first byte's
start bit transition would be invisible because the scope's pre-first-
sample default is LOW.
Both USART construction sites (initial setupSimulation around line 423,
re-init after stop around line 749) get the same hook.
Covered by `__tests__/avr-uart-tx-waveform.test.ts` (5 cases): idle seed,
byte with internal transitions, 0xFF edge case, TXEN-disabled no-op,
bit-period timing.
The previous test fed `xValue: 0, yValue: 1023` to the analog-joystick
handler and asserted X=0V, Y=5V. That was wrong for two reasons:
1. wokwi-analog-joystick emits direction (-1/0/+1), never 0..1023, so
the fixture didn't match how the real component behaves.
2. The OLD `(value/1023) * vcc` mapping happened to produce 0V for 0
and 5V for 1023, so the broken test still passed against the
broken handler — and now breaks against the correct one.
Switch the fixture to `xValue: -1, yValue: 1` so the expectations line up
with the corrected handler (-1 → 0V, +1 → Vcc) and exercise the same
voltage rails the real component will produce.
The PartSimulationRegistry handler for 'analog-joystick' was reading
`el.xValue` / `el.yValue` and computing `(value / 1023) * vcc` as if the
component were a potentiometer producing a raw 0..1023 reading. It is
not — `@wokwi/elements/analog-joystick-element` emits xValue / yValue as
a tri-state DIRECTION signal:
* xValue = -1 → "left" (mousedown on left zone)
* xValue = 0 → centered (mouseup snap-back)
* xValue = +1 → "right" (mousedown on right zone)
(same for yValue with up/down)
`(±1) / 1023 ≈ ±0.001`, so the ADC channel sat at ~0 V no matter which
directional zone was clicked. Center-button clicks worked because that
path is digital (`setPinState(SEL, …)`) and bypasses the analog map.
Fix:
* Tri-state → voltage with explicit map: -1 → 0V, 0 → Vcc/2, +1 → Vcc.
* Vcc was hardcoded to 5V for "not RP2040" — wrong for ESP32 / S3 /
Nano-ESP32 / etc., which all run at 3.3V like the Pi Pico. Detect
ESP32 via the BridgeShim's `setAdcVoltage` method and select 3.3V
for everything that isn't pure AVR.
Reported on /example/esp32-joystick where center-button-only worked but
directional zones did nothing. Verification via Chrome MCP after deploy.
The user_libs_all bundler in _resolve_library_components does BFS over the
sketch's external includes, copying each matching Arduino library into
one merged IDF component. Anything not in _BUILTIN_HEADERS is treated as
an external library to resolve, and the lookup just scans
/root/Arduino/libraries/ for a directory whose `src/` (or root) holds a
matching header file.
_BUILTIN_HEADERS only listed C headers (stdint.h, stdio.h, …). The C++
wrappers (cstdint, cstdio, cmath, …) and the STL containers (vector,
complex, string, …) were absent. Result: any library transitively
#including <cstdint> or <vector> caused the bundler to "resolve" the
header against /root/Arduino/libraries/ArduinoSTL/ — an AVR-only
uClibc++ port that ships every C++ stdlib header as plain files. Once
ArduinoSTL was matched the bundler dragged in ALL of it, including
complex.cpp:
template class _UCXXEXPORT complex<float>;
which fails on the ESP-IDF Xtensa toolchain because _UCXXEXPORT isn't
defined in that compile context AND the symbol already exists in the
real libstdc++ pulled in by <complex>. Net effect: every ESP32 sketch
whose deps transitively include a C++ stdlib header (e.g. ESP32Servo
includes <cstdint>) blew up with 66+ errors before the servo example
even reached the link step.
Fix: extend _BUILTIN_HEADERS to cover the full set of C++ stdlib
wrappers and STL headers so the bundler never treats them as installable
libraries. The Xtensa GCC + libstdc++ shipped by ESP-IDF provides them
natively; ArduinoSTL never has any business being part of an ESP32 build.
Verified end-to-end on /example/esp32-servo: compile now succeeds, sketch
boots, moving the potentiometer drives the wokwi-servo angle (Pot=2801 →
Angle=123 deg, servo arm rotates).
arduino-esp32 3.x ledcWrite takes a PIN and looks up the attached channel
internally. arduino-esp32 2.x (the toolchain version we pin) takes a
CHANNEL. The velxio_compat.h shim already aliased the 3.x-only
ledcAttach onto ledcSetup+ledcAttachPin so 3.x sketches would compile,
but ledcWrite still mapped 1:1 — so a call like
#define R_PIN 16
ledcAttach(R_PIN, 5000, 8); // shim → channel 0 attached to pin 16
ledcWrite(R_PIN, 128); // ★ writes to "channel 16" (invalid)
silently wrote to LEDC channel 16, which doesn't exist (valid range
0-15). The hardware duty register never changed, qemu-lcgamboa never
emitted a `ledc_duty` event, and the RGB LED stayed dark even though
the firmware ran cleanly and the wires looked right. Verified end-to-end
with examples/esp32-pwm-led-rgb: gpio_change events fired at boot, no
ledc_duty events fired, ledRed/ledGreen/ledBlue all stayed at 0.
Fix: maintain a 40-entry pin→channel table populated by both ledcAttach
variants. Replace ledcWrite with a macro that calls a helper checking
the table first; if the value isn't a known pin we pass it through as a
channel, preserving 2.x channel-style call sites.
Macro/function name collision is sidestepped with the standard
parenthesizing trick — `(ledcWrite)(channel, duty)` doesn't expand the
function-like macro because the token isn't followed by `(`.
Verified live on velxio.dev/example/esp32-pwm-led-rgb after hot-copying
the new header into the velxio-app container: ledRed/ledGreen/ledBlue
now cycle through the full HSV wheel as expected (samples: (255,41,0),
(41,255,0), (0,41,255), (232,255,0), …).
Single-file sketch only — the table is `static` (internal linkage) and
ledcAttach + ledcWrite live in the header. Multi-file sketches that
attach in file A and write in file B would each see their own table.
Acceptable for now since arduino-esp32 sketches are nearly always
single-file; revisit when we bump the toolchain to 3.x and can drop the
shim entirely.
ATTinyCore >=1.5.0 declares ATTinyCore:micronucleus@2.5-azd1b as a tool
dependency, hosted at https://azduino.com/bin/micronucleus/. That host
has been unreachable (connection refused) for extended periods, causing
every ATtiny85 compile to fail at the core-install step with:
Download failed: performing HEAD request: ... dial tcp ...: connection refused
Failed to install required core: ATTinyCore:avr
micronucleus is only used for USB upload — never for compilation — but
arduino-cli refuses to install a core whose tool deps cannot fetch.
Pin to 1.4.1, the last release whose micronucleus binary is hosted on
github.com (digistump release, reachable). The FQBN clock options we
ship (clock=16pll on attinyx5, etc.) are unchanged across 1.4.x.
- backend/app/services/arduino_cli.py: new CORE_INSTALL_VERSIONS map
consulted by ensure_core_for_board so the runtime auto-install
passes "ATTinyCore:avr@1.4.1" instead of unversioned latest.
- backend/Dockerfile and docker/entrypoint.sh: same pin so a fresh
image bakes 1.4.1 in and never hits the runtime fallback path.
Existing regression tests in test/backend/unit/test_arduino_cli_attinycore.py
still pass (they assert presence, not version).
Two coordinated copy/layout changes on the landing hero:
- Primary CTA label gets "Online" added across the 9 supported
locales — "Try Simulator Free Online →" / "Probar el simulador
online gratis →" / etc. Reason: with the Velxio Desktop
download path now live, users should immediately understand
that the green button is the BROWSER version, and there's a
local install option for people who want it faster offline.
- The pro-overlay slot for the desktop download CTA was
`landing-hero-primary-cta` and sat ABOVE the hero CTAs. Renamed
to `landing-hero-download-cta` and moved BELOW so the funnel
reads "try online (primary) → or install locally (secondary)"
instead of "install locally (primary) → try online (secondary)".
OSS layout is unchanged — the slot is still empty in pure builds.
Also picks up the auto-regenerated sitemap.xml lastmod dates from a
recent deploy (every URL bumped 2026-05-19 → 2026-05-21).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the missing `.vscodeignore` so future `vsce package` runs don't
bundle the webview's `node_modules/` (which made the v0.1.0 release
17 MB instead of <1 MB — webview is a thin React WebView app that
ships only its compiled bundle).
With the ignore in place, 0.2.0 packages down to 100 KB (10 files:
extension.js + webview index.js + manifest + LICENSE + README +
changelog + icon + diagram schema). Matches the precedent of
committing the .vsix alongside the source for offline installation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the Velxio Pro subscription gate to the VS Code extension. Every
compile / run validates against `https://velxio.dev/api/pro/license/validate`
before proceeding; a 60-second in-memory cache avoids hammering the
endpoint during a tight compile/run loop. No offline mode by design —
the extension throws OfflineError on network failure rather than
caching a permission grant locally. For offline workflows users get
the desktop app (separate distribution channel).
New surface:
- `LicenseService` (src/LicenseService.ts) — secret-store-backed key
storage, validate, nonce-backed deep-link OAuth handshake,
OfflineError + EntitlementError taxonomy.
- `Velxio: Sign In` — opens velxio.dev/auth/vscode, returns
via vscode://velxio.velxio-simulator/auth.
- `Velxio: Paste License Key` — manual fallback for headless boxes.
- `Velxio: Sign Out` — clears the keychain entry.
- `Velxio: Show License Status` — plan + trial countdown modal.
- Status bar item: Sign in / Trial Nd / Pro / Trial ended with the
appropriate warning/error background colour.
- Setting `velxio.licenseApiBase` for staging overrides.
CHANGELOG.md + README.md added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New `frontend/src/desktop/` module loaded only when VITE_DESKTOP=true.
Hosts the Tauri-bound UI that pure OSS doesn't need:
- tauriBridge.ts typed invoke / listen / openExternal / beginSignIn
wrappers with no-op fallbacks for `vite dev` outside
the Tauri webview.
- DesktopWelcomePage.tsx sign-in flow with browser handoff +
paste-key fallback; listens for
`velxio://auth-completed` from the shell.
- GraceBanner.tsx renders soft/hard grace banners driven by
`license_status` + `velxio://license-status`
emits from the background checkin loop.
Toggles `body.vlx-desktop-readonly` so the
editor's Save / Compile buttons disable
themselves via CSS in hard-grace.
- Esp32QemuPrompt.tsx one-time download modal when the user picks
an ESP32 board on a fresh install.
- index.ts mounts welcome conditionally and the side
panels unconditionally.
- desktop.css shared styles.
All entry points are no-ops outside Tauri so the existence of the
folder has zero effect on the OSS build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `lib/apiBase.ts` so the SPA can be repointed at a non-default backend
at runtime (via `window.__VELXIO_API_BASE__`) without losing the existing
`VITE_API_BASE` build-time override or the default `/api` reverse-proxy
behaviour. compilation / libraryService / projectService / metricsService
all flow through it now; axios clients use a request interceptor so the
base resolves per-request rather than at module-load time.
main.tsx grows a `VITE_DESKTOP` flag: when set, the @pro overlay is
skipped (the desktop shell handles license + auth natively) and a
small `./desktop/index` module is dynamic-imported in its place. OSS
builds tree-shake both branches.
LandingPage gets a `data-velxio-slot="landing-hero-primary-cta"` marker
above the existing hero CTAs so velxio.dev can inject an OS-detect
"Download Velxio Desktop" button as the visual primary. The slot is
empty in pure OSS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On the ESP32 DevKit V1 the silkscreen labels GPIO 16 / 17 as RX2 / TX2,
and Esp32Element.PINS_ESP32 only exposed the silkscreen names. Examples
that wire to numeric pin "16" or "17" (e.g. ledcAttach(16, 5000, 8) on
esp32-pwm-led-rgb) couldn't resolve those names — pinPositionCalculator
failed lookups, the wire endpoint fell back to (0,0)/(50,50) and the
LED component visually floated off the board, breaking the SPICE
netlist for the example.
Add "16" and "17" as aliases pointing to the same (134,143) / (134,131)
coordinates as RX2 / TX2 so both naming conventions resolve to the same
physical pin tip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ExampleThumbnail.tsx only serves /examples-thumbs/<id>.webp; the .png
copies were sharp's intermediate format committed by mistake on
2026-05-19 (commit 8693f93) when scripts/refresh-example-thumbs.sh
copied both formats to public. Removing ~18 MB of unreferenced PNGs
keeps the public folder lean. WebP is supported by ~97% of browsers
in use; the old Safari fallback path goes through CircuitPreview
(the SVG mock), not the .png.
Generated by velxio-prod's scripts/capture-example-thumbs.mjs after
fixing three bugs (CTA-based navigation against renamed route,
heterogeneous data-file parsing, false-positive prefix match). The
gallery now has a real canvas screenshot for every example
exampleProjects exports — 263 / 263. Previously 44 examples (mostly
digital-* and i8080-* / z80-*) had no preview and rendered the
SVG mock fallback.
Two related bugs that surfaced as "Vitest worker exited unexpectedly /
Timeout terminating forks worker" on the circuit-simulation-service
test file.
Bug 1 — tick() recursively re-schedules itself in its finally block.
After afterEach disposes the scheduler via __resetMixedModeScheduler(),
those re-scheduled ticks throw "call loadCircuit first", get caught by
the console.warn, and the finally schedules ANOTHER tick. Infinite
Promise loop survives until the worker OOMs.
Fix: add CircuitSimulationService.stop() that flips a `stopped` flag
short-circuiting tick() + handleMcuEdge(). The test harness now
tracks each started service in _activeServices and calls stop() in
afterEach alongside the existing unsubscribe sweep.
Bug 2 — when an MCU edge fires on a pin that's NOT wired into any net
(buildNetlist skips it because netLookup returns null), handleMcuEdge
sees hasSource=false, self-heals by queueing the edge + tick(). The
rebuild still doesn't emit the V-source (no wire), so tick.finally
replays the edge → self-heal again → tick again → infinite loop AT
RUNTIME, not just in tests. A user toggling a digital pin without a
wire freezes the whole circuit simulation.
Fix: in tick.finally's pendingMcuEdges replay loop, check whether
the rebuilt netlist now contains a V-source for each pending edge's
pin. If not, drop the edge silently — a future canvas tick triggered
by adding the wire will pick it up via the normal subscription path.
Also fix the "coalesces an edge with an in-flight full solve" test
fixture: simpleBoardWithBoard leaves pin 9 unwired, so V_uno_9 was
never emitted and the test was racing the (now-bounded) self-heal
rebuild. Replaced with an inline fixture wiring pin 9 → resistor →
GND, mirroring the wired fixture used by the alter+republish test
right above it.
Full vitest --shard 1/2 + 2/2 pass cleanly (1886 tests, 22-29s per
shard) with no worker-exit warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three failures introduced by 603b791 (which added
MAPPERS['photoresistor-sensor'] = MAPPERS['photoresistor'] so the
metadata-id 'photoresistor-sensor' resolves to a SPICE mapper):
- component-to-spice.test.ts "every mapped metadataId has a test fixture"
flagged photoresistor-sensor as missing. Added a fixture entry that
mirrors the photoresistor one — the part is electrically identical.
- examples-netlist-snapshot.test.ts > photoresistor-light and
> nano-sensor-station snapshots now contain R_ldr_ldr + R_ldr_pull
cards (correct LDR + 10k pull) instead of the previous
R_autopull_n0 100M stub. This is the intended behaviour change:
before the alias the LDR was unmapped and the netlist autopulled the
net to ground with a 100M dummy; after the alias the SPICE deck
carries the real divider topology. Regenerated only these two
snapshot entries (vitest -u on the single file).
No production code changes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ATtiny85 (AVRSimulator + collectPinStates + connectAnalogInputsToMcu + SimulatorCanvas + Attiny85Element + examples):
- Add attiny85AdcConfig with correct register addresses (ADMUX=0x27,
ADCSRA=0x26, ADCSRB=0x23, ADCL=0x24, ADCH=0x25, DIDR0=0x34, adcInterrupt=0x08).
Without this, analogRead() polled the wrong address forever and the
firmware hung on first ADC read.
- Add attiny85Timer0Config + instantiate AVRTimer so OVF fires at the
ATTinyCore-expected ~1.024 ms cadence. delay() advance is still blocked
on avr8js TIFR auto-clear semantics (separate upstream issue, see
ATTINY85_TIMER0_UPSTREAM_ISSUE.md in velxio-prod test plan).
- Map ATtiny85 ADC channels to PB-style pin names (PB5/PB2/PB4/PB3 -> 0..3)
in connectAnalogInputsToMcu so SPICE node voltages reach the right ADC
channel.
- Recognise /^PB\d+$/ in collectPinStates.pinNameToArduinoPin so wires
named "PB1" emit v_attiny85_pb1 V-source and the LED responds to MCU
writes. Previously every PB-wire returned -1 and SPICE saw no source.
- SimulatorCanvas: subscribe pin 1 (PB1) for the built-in LED on the
attiny85 board kind (Digispark convention), instead of falling through
to the pin-13 default.
- Attiny85Element: remove the hand-drawn "yellow LED" circle that was
floating above the chip. The bare DIP-8 has no on-board LED; examples
wire a real wokwi-led + resistor instead.
- examples.ts: add a real wokwi-led + 220 Ohm wokwi-resistor + wires to
attiny85-blink, and add missing series resistors to attiny85-button-led
and attiny85-ntc-sensor. attiny85-pwm-fade was already correct.
Custom-chip pipeline (CustomChipPart + simulatorBridges):
- Add a requestAnimationFrame loop that calls instance.tickTimers() every
frame in CustomChipPart. Chips that register vx_timer_create (e.g. an
i8080 stepping its core, or a sensor publishing samples) had timers
added to the queue but nothing fired them; tickTimers was dead code.
- Gate the ESP32 backend path with detectSimulatorKind(sim)==='esp32'.
The previous `typeof sim.registerSensor === 'function'` check matched
AVR and RP2040 simulators too (they expose registerSensor for I2C
sensor proxies), routing client-side chips to a non-existent ESP32
worker on those boards.
- Replace direct simulator.usart.writeByte calls in avrUartTx with a
JS-level FIFO + setTimeout(1ms) drainer. avr8js writeByte drops bytes
under burst load (a chip emitting print_string lost ~99% of bytes via
non-immediate, or kept only the last byte via immediate). The drainer
attempts one non-immediate write per tick and retries on RXC busy /
RXEN off. Added a guard for ATtiny85 (no USART -> would queue forever).
End-to-end verified: i8080-banner-streamer now prints the boot banner
followed by "uptime ticks: 0xNN" lines stepping every ~50 ms, executing
real Intel 8080 instructions inside the WASM chip.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous fix (dd22bcf) used `isInteractive` to decide whether to let
the wokwi component own the pointerdown. That heuristic was too broad —
DHT22, HC-SR04, NTC, photoresistor, LED all register `attachEvents` for
the SPICE/sensor-update bridge but have NO internal pointer handlers, so
clicks on them got silently swallowed by the wokwi shadow DOM and the
property dialog never opened.
Replace with an explicit whitelist of wokwi tags that ACTUALLY own
pointerdown (rotary knobs, pushbuttons, slide switches, joysticks,
keypads, encoders, rotary dialer). Every other component, including
sensors/displays/LEDs with attachEvents, falls through to the canvas
which decides between drag-to-rearrange and click-to-open-dialog.
Documented the model in docs/wiki/component-interaction.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three independent fixes uncovered during a systematic example-by-example
audit (plan/full_test_plan/):
1. DynamicComponent.handleMouseDown was calling e.stopPropagation()
unconditionally in the capture phase. That swallowed pointerdown
BEFORE wokwi-potentiometer / pushbutton / slide-switch / joystick
could see it, so the rotary knob would not rotate and buttons
wouldn't press even with a real OS mouse. Now we skip the swallow
when the click target is an inner wokwi-* element during a live
simulation, letting the wokwi component own its own pointerdown
while still allowing the canvas drag-to-rearrange flow on the
wrapper / non-interactive surface.
2. examples.ts uno-ntc (and pico-ntc) sketch had the NTC divider
formula inverted relative to both the SPICE mapper topology
(VCC -> R_NTC -> A1 -> R_pull -> GND, the standard module wiring)
and real wokwi-ntc-temperature-sensor modules. Moving the slider
to 60 C made the firmware print -3.42 C. Flipped the formula to
r = SERIES_R * (VCC - v) / v. Now slider 60 C -> Serial reports
60.12 C and A1 voltmeter shows 4.00 V.
3. componentToSpice.ts photoresistor mapper was only registered under
the bare key `photoresistor`, but example components use the
metadataId `photoresistor-sensor`. Added an alias so the LDR +
pull-down divider gets emitted for the real component instance.
All three reproduce visually in seconds; documented per-example in
plan/full_test_plan/examples/.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two issues in PR #200 became obvious from the next CI run:
1. **`poolOptions.forks.execArgv` had no effect.** Vitest 4 removed
`test.poolOptions` entirely — every key under it moved to
top-level `test.*`. The runner emits this banner on every run:
DEPRECATED `test.poolOptions` was removed in Vitest 4.
All previous poolOptions are now top-level options.
So `test.poolOptions.forks.execArgv: ['--max-old-space-size=8192']`
was silently ignored. Move it to `test.forks.execArgv` (and
`test.forks.singleFork`).
2. **NODE_OPTIONS got dropped when the shard step was rewritten.**
PR #199 added `env: NODE_OPTIONS: --max-old-space-size=8192` to
the `npm test` step; PR #200 replaced the step with `npx vitest
run --shard X/2` but did not carry the env-var across. Combined
with #1, the workers reverted to Node's 4 GB default and shard 2
(58 files) still OOMs at exactly 4128 MB heap.
Restore NODE_OPTIONS on the workflow step as belt-and-braces — it
gets honored by the parent vitest process, and the migrated config
covers the forked children.
With 8 GB heap × 2 shards × 58 files each, the cumulative state
from ngspice WASM + singletons fits comfortably and the suite
should exit cleanly.
The execArgv fix (PR #200) made vitest actually honor the 8 GB heap
cap — and the next CI run promptly proved that 8 GB is still not
enough. Log: every test passes but the worker hits
"Ineffective mark-compacts near heap limit" at exactly 8011 MB,
the new ceiling. Doubling again to 16 GB would be near the
GitHub-runner total RAM (16 GB) and start swapping.
The real culprit is per-file leak accumulation: 117 test files
share one vitest fork; each file lazy-loads ngspice WASM
(~24 MB), wires up MixedModeScheduler / zustand singletons, and
leaves some of that state alive in module-level closures even
after the file finishes. Sum over the suite ≈ 8 GB+ retained.
Split the run with vitest's built-in `--shard N/M`:
- matrix.shard: [1, 2] alongside matrix.node-version: [20, 22]
= 4 parallel runners
- each runner executes `npx vitest run --shard ${shard}/2`
- vitest hashes file paths into deterministic shards (same
flaky file always lands in the same shard)
- each runner only carries ~60 files of leak state → fits in
the existing 8 GB cap from poolOptions.forks.execArgv
Coverage upload gated to shard 1 / node 22 to avoid the two
shards racing to overwrite the same artifact name. Coverage
itself runs once on the full suite (best-effort, may OOM, but
`continue-on-error: true` keeps it non-blocking).
The real fix is dispose hooks on the leaking singletons, but
that's a multi-PR cleanup of code paths I haven't touched in
this work item; sharding unblocks CI in the meantime.
PR #198 added NODE_OPTIONS=--max-old-space-size=8192 to the
frontend-tests workflow assuming vitest's forks pool would inherit
it. It does NOT. Vitest 4's forks pool spawns workers via
child_process.fork() with an explicit execArgv list and ignores
the parent shell's NODE_OPTIONS env var — verified by reading the
post-merge GHA log: the Node OOM still fires at ~4.0 GB heap,
exactly the default v8 ceiling.
Set the heap cap at the pool level instead so the workers actually
see it. This is the canonical vitest 4 idiom for raising worker
limits — `poolOptions.forks.execArgv` is forwarded verbatim to
each forked child.
Independent of: the gpio_matrix_cb SIGSEGV fix in qemu-lcgamboa
(now landed) which addresses the Backend E2E failure mode. This
PR is exclusively the Frontend Tests heap fix.
This also serves as the trivial commit needed to re-trigger the
master CI run against the now-fixed libqemu binaries (v1.1.1
served from the license endpoint).
Two CI failures landed after PR #196 (esp32-gpio-matrix-cb-callback)
merged. Both are independent and fixed here together.
1) **Backend E2E: ESP32 hangs at bootloader handoff.**
PR #196 added picsimlab_gpio_matrix_cb which fires on QEMU's
iothread. The handler did `_emit({...})` for every routing
change — and the ESP-IDF bootloader writes to gpio_out_sel
*hundreds* of times during early boot (each peripheral init
configures its matrix slot). Each emit acquires _stdout_lock
and writes to the worker→manager pipe. If the manager drains
even briefly slow, the pipe fills, write blocks, and the
iothread stalls — symptom: ESP32 reports `entry 0x400805e4`
then no Arduino setup() output for 75 s.
Fix: the iothread callback now ONLY mutates the SignalRouter
snapshot. It never emits. The 10 Hz poll thread
(_refresh_signal_routing) stays as the sole emitter, so the
wire-format event stream is unchanged. Benefit of having the
callback over poll-only is reduced worst-case routing-emit
latency (next poll tick vs up to 100 ms) and a warmer
snapshot dict for cheaper poll diffs.
2) **Frontend Tests: Node OOM at end of suite.**
117 test files run in one forks-pool worker. Several lazy-load
the ngspice emscripten module (~30 MB), the MixedModeScheduler
singleton, and other heavy modules whose dispose hooks aren't
reached because singletons leak across files. Cumulative heap
pressure exceeds Node's 4 GB default; the worker hits "Ineffective
mark-compacts near heap limit" AFTER all 1881 tests pass and
the OOM kill is reported by vitest as "Worker exited unexpectedly
/ Timeout terminating forks worker". This is not a real test
failure — every individual test passes.
Quick fix: pass NODE_OPTIONS=--max-old-space-size=8192 to the
`npm test` step. Long-term, the singletons should add dispose
hooks that test fixtures call in afterAll(), or the suite
should shard into multiple `vitest run --shard` invocations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two CI failures landed together on master after PR #194 merged:
1. **components-metadata.json stale.** The `power-supply` thumbnail
in scripts/component-overrides.json was updated (grey placeholder
→ branded PSU SVG with voltage/current labels) but the generated
JSON wasn't regenerated. The pre-merge check
`git diff --quiet frontend/public/components-metadata.json` now
fails on master. Fix: `cd frontend && npm run generate:metadata`,
commit the result.
2. **Frontend Tests > test (20/22): vitest worker hang.**
`circuit-simulation-service.test.ts` had been calling
`service.start()` in ~10 tests without storing the returned
unsubscribe handle. Each call subscribes the service to the
simStore; the listener captures the service + scheduler in
its closure. After all tests complete, vitest's forks pool
tries to terminate the worker but the still-active listeners
keep the event loop pinned, producing:
"Worker exited unexpectedly / Timeout terminating forks worker"
All assertions actually pass — only the worker shutdown hangs.
Fix: introduce a `startTracked(service)` helper that records
the unsubscribe in a module-level array, plus an `afterEach`
that drains the array. `__resetMixedModeScheduler()` still runs
after to dispose the scheduler singleton. Replaced all 9 raw
`service.start()` callsites.
Both are independent of any production code change. The fix is
test/scaffolding only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>