diff --git a/AGENTS.md b/AGENTS.md index 098dc16e2..09d82b203 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,7 +93,7 @@ Adding a cross-boundary feature: - **Source policy (Vitest registry):** file-local / small-glob invariants live in [`src/architecture/sourcePolicyRules.ts`](src/architecture/sourcePolicyRules.ts) + walker [`sourcePolicy.ts`](src/architecture/sourcePolicy.ts) — prefer adding a rule there over a new `scripts/check-*.mjs`. Suppress with `// source-policy-ok `. Pre-commit always appends `src/architecture/sourcePolicy.test.ts` when any TypeScript under `src/` is staged (the registry is not import-related). Keep cross-cutting always-on hygiene in existing `check:*` scanners. - **Reticulum sidecar (Rust):** Clippy + rustfmt via `pnpm run check:reticulum-sidecar` (full-feature fmt + Clippy + test when `cargo` is on `PATH` **and** sidecar-related paths are staged) and the same feature set in `reticulum-sidecar.yaml`. Coverage threshold (`cargo llvm-cov --fail-under-lines`) is enforced only in `tests.yaml` when sidecar paths change — not in pre-commit. - **Temp dirs in tests:** Use `mkdtempSync(path.join(os.tmpdir(), 'prefix-'))` — never write to a fixed name under `os.tmpdir()` (CodeQL + `check:insecure-temp-files`). -- Vitest worker pool sizes and shared Vite dep inline lists live in `vitest.harness.ts` — update when adding deps that need inlining. +- Vitest worker pool sizes and shared Vite dep inline lists live in `vitest.harness.mts` — update when adding deps that need inlining. - Prefer `mockConsoleWarn` / `withMockedConsoleWarn` from `src/renderer/lib/vitestConsoleMock.ts` over ad-hoc `vi.spyOn(console, 'warn')` in renderer tests. - Monolithic runtimes (`useMeshtasticRuntime`, `useMeshcoreRuntime`, `noble-ble-manager`) may use **source contract tests** (`sourceContractTestHelpers.ts`, `*.reconnect*.test.ts`) when full integration mocking is impractical — see [development-environment.md](docs/development-environment.md#vitest-projects-and-worker-allocation). Runtime contract tests that load `use*Runtime.ts` must use `loadRuntimeSource()` (enforced by source policy). - Mock console before spying logged errors: `vi.spyOn(console, 'warn').mockImplementation(() => {})` in `beforeEach` when shared. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dbd46d3e7..2ba1d16af 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -16,7 +16,7 @@ Path alias `@/*` maps to `src/*` (see `tsconfig.json`). **Entry points:** `src/main/index.ts`, `src/preload/index.ts`, `src/renderer/main.tsx`, `src/renderer/App.tsx`. -**Repo root (not exhaustive):** `.github/workflows/`, `scripts/check-*.mjs` (IPC, migrations, log injection, etc.), `docs/`, `resources/`, `vite.config.ts`, `electron-builder.yml`, `package.json`. +**Repo root (not exhaustive):** `.github/workflows/`, `scripts/check-*.mjs` (IPC, migrations, log injection, etc.), `docs/`, `resources/`, `vite.config.mts`, `electron-builder.yml`, `package.json`. ## Process boundaries diff --git a/docs/development-environment.md b/docs/development-environment.md index 7d8629a31..f0d9aa59d 100644 --- a/docs/development-environment.md +++ b/docs/development-environment.md @@ -519,7 +519,7 @@ Not installed by pnpm (install separately when needed): #### Vitest projects and worker allocation -`vitest.config.ts` defines three projects: +`vitest.config.mts` defines three projects: | Project | Environment | Role | | ---------------- | ----------- | --------------------------------------- | @@ -527,7 +527,7 @@ Not installed by pnpm (install separately when needed): | `renderer-logic` | node | Pure renderer unit tests (no setup) | | `main` | node | Main, shared, preload, and script tests | -Worker counts are derived in [`vitest.harness.ts`](../vitest.harness.ts) via `computeVitestMaxWorkers(cpuCount, ratio)`: jsdom workers use `RENDERER_UI_CPU_RATIO` because they are memory-heavy; node workers use `NODE_WORKER_CPU_RATIO`. Both pools floor at `MIN_VITEST_WORKERS` (currently 2) and cap effective CPU count at `MAX_VITEST_CPU_COUNT` (32). When tuning worker allocation, change those constants in the harness (not this doc). Shared Vite dependency inline lists (`VITEST_CORE_DEPS`, `VITEST_SERVER_INLINE_DEPS`) also live there — add new deps to the harness when tests need them inlined. +Worker counts are derived in [`vitest.harness.mts`](../vitest.harness.mts) via `computeVitestMaxWorkers(cpuCount, ratio)`: jsdom workers use `RENDERER_UI_CPU_RATIO` because they are memory-heavy; node workers use `NODE_WORKER_CPU_RATIO`. Both pools floor at `MIN_VITEST_WORKERS` (currently 2) and cap effective CPU count at `MAX_VITEST_CPU_COUNT` (32). When tuning worker allocation, change those constants in the harness (not this doc). Shared Vite dependency inline lists (`VITEST_CORE_DEPS`, `VITEST_SERVER_INLINE_DEPS`) also live there — add new deps to the harness when tests need them inlined. By default all three Vitest projects run in **parallel** (`groupOrder: 0`). On memory-constrained hosts, set `VITEST_SEQUENTIAL_PROJECTS=1` to run `renderer-ui` first, then `renderer-logic` + `main` together (legacy behavior). diff --git a/eslint.config.mjs b/eslint.config.mjs index d861a059c..f8ca22cde 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -22,8 +22,8 @@ export default tseslint.config( 'eslint.config.mjs', '**/*.d.ts', 'eslint.config.*', - 'vitest.config.ts', - 'vite.config.ts', + 'vitest.config.mts', + 'vite.config.mts', '*.config.{ts,js,mjs,cjs}', 'dist-electron/**', 'coverage/**', diff --git a/package.json b/package.json index 179e125db..8ea109675 100644 --- a/package.json +++ b/package.json @@ -202,6 +202,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-security": "^4.0.1", "eslint-plugin-simple-import-sort": "^13.0.0", + "fake-indexeddb": "^6.2.5", "jsdom": "^29.1.1", "leaflet": "^1.9.4", "license-checker-rseidelsohn": "^4.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 422c9fa12..7535e93d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -231,6 +231,9 @@ importers: eslint-plugin-simple-import-sort: specifier: ^13.0.0 version: 13.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 jsdom: specifier: ^29.1.1 version: 29.1.1(@noble/hashes@2.2.0) @@ -2345,6 +2348,10 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -7034,6 +7041,8 @@ snapshots: exponential-backoff@3.1.3: {} + fake-indexeddb@6.2.5: {} + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} diff --git a/scripts/precommit-tests.mjs b/scripts/precommit-tests.mjs index 45af6b5e3..5c761052c 100644 --- a/scripts/precommit-tests.mjs +++ b/scripts/precommit-tests.mjs @@ -134,6 +134,7 @@ function isMainProjectPath(filePath) { p.startsWith('src/architecture/') || p.startsWith('scripts/') || p === 'vitest.harness.ts' || + p === 'vitest.harness.mts' || p === 'vitest.harness.test.ts' ); } diff --git a/scripts/precommit-tests.test.mjs b/scripts/precommit-tests.test.mjs index db44fb808..24ccab2d7 100644 --- a/scripts/precommit-tests.test.mjs +++ b/scripts/precommit-tests.test.mjs @@ -16,7 +16,9 @@ import { describe('precommit-tests force-full', () => { it('detects vitest harness and lockfile', () => { expect(isForceFullSuitePath('vitest.harness.ts')).toBe(true); + expect(isForceFullSuitePath('vitest.harness.mts')).toBe(true); expect(isForceFullSuitePath('vitest.config.ts')).toBe(true); + expect(isForceFullSuitePath('vitest.config.mts')).toBe(true); expect(isForceFullSuitePath('package.json')).toBe(true); expect(isForceFullSuitePath('pnpm-lock.yaml')).toBe(true); expect(isForceFullSuitePath('src/renderer/vitest.setup.ts')).toBe(true); @@ -159,7 +161,7 @@ describe('precommit-tests runPrecommitTests', () => { describe('precommit-tests shouldForceFullSuite', () => { it('is true when any force-full path is present', () => { - expect(shouldForceFullSuite(['src/main/index.ts', 'vitest.config.ts'])).toBe(true); + expect(shouldForceFullSuite(['src/main/index.ts', 'vitest.config.mts'])).toBe(true); expect(shouldForceFullSuite(['src/main/index.ts'])).toBe(false); }); }); diff --git a/src/main/vite-config.contract.test.ts b/src/main/vite-config.contract.test.ts index f63221818..4e0cba0c8 100644 --- a/src/main/vite-config.contract.test.ts +++ b/src/main/vite-config.contract.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from 'fs'; import { join } from 'path'; import { describe, expect, it } from 'vitest'; -const VITE_CONFIG = readFileSync(join(__dirname, '../../vite.config.ts'), 'utf-8'); +const VITE_CONFIG = readFileSync(join(__dirname, '../../vite.config.mts'), 'utf-8'); describe('vite build config', () => { it('disables source maps for production bundles', () => { @@ -14,4 +14,9 @@ describe('vite build config', () => { it('sets chunk size warning limit for Electron renderer advisory threshold', () => { expect(VITE_CONFIG).toMatch(/chunkSizeWarningLimit:\s*1000/); }); + + it('uses import.meta.dirname for native ESM configLoader compatibility', () => { + expect(VITE_CONFIG).not.toMatch(/__dirname/); + expect(VITE_CONFIG).toMatch(/import\.meta\.dirname/); + }); }); diff --git a/src/renderer/lib/protocols/meshcore/MeshCoreTransport.ipc-cleanup.test.ts b/src/renderer/lib/protocols/meshcore/MeshCoreTransport.ipc-cleanup.test.ts index 58ca01e38..2517163fd 100644 --- a/src/renderer/lib/protocols/meshcore/MeshCoreTransport.ipc-cleanup.test.ts +++ b/src/renderer/lib/protocols/meshcore/MeshCoreTransport.ipc-cleanup.test.ts @@ -2,7 +2,7 @@ * @vitest-environment jsdom * * Needs window.electronAPI; keep in renderer-logic via per-file jsdom rather than - * listing in vitest.config.ts RENDERER_LOGIC_EXCLUDE (eslint-ignored config). + * listing in vitest.config.mts RENDERER_LOGIC_EXCLUDE (eslint-ignored config). */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; diff --git a/src/renderer/vitest.setup.ts b/src/renderer/vitest.setup.ts index 2f93742f6..c3aaa1cfc 100644 --- a/src/renderer/vitest.setup.ts +++ b/src/renderer/vitest.setup.ts @@ -1,5 +1,7 @@ import '@testing-library/jest-dom'; import 'vitest-axe/extend-expect'; +// emoji-picker-element (ChatPanel) opens IndexedDB; jsdom has none. +import 'fake-indexeddb/auto'; import { cleanup } from '@testing-library/react'; import i18next from 'i18next'; @@ -11,6 +13,59 @@ import en from './locales/en/translation.json'; import { createElectronAPIMock, resetElectronAPIOutboxMock } from './vitest.electronApiMock'; expect.extend(matchers); + +// jsdom omits structuredClone; fake-indexeddb requires it (Node engines provide it). +if (typeof globalThis.structuredClone === 'function') { + vi.stubGlobal('structuredClone', globalThis.structuredClone.bind(globalThis)); +} + +/** Empty DOMRect for jsdom Range / IntersectionObserver stubs. */ +function emptyDomRect(): DOMRect { + return { + x: 0, + y: 0, + width: 0, + height: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + toJSON() { + return this; + }, + }; +} + +// emoji-picker-element uses IntersectionObserver after IndexedDB opens (jsdom has none). +interface IntersectionObserverStubInstance { + root: null; + rootMargin: string; + thresholds: number[]; + observe: ReturnType; + unobserve: ReturnType; + disconnect: ReturnType; + takeRecords: ReturnType; +} +vi.stubGlobal( + 'IntersectionObserver', + vi.fn(function IntersectionObserverStub(this: IntersectionObserverStubInstance) { + this.root = null; + this.rootMargin = ''; + this.thresholds = []; + this.observe = vi.fn(); + this.unobserve = vi.fn(); + this.disconnect = vi.fn(); + this.takeRecords = vi.fn(() => []); + }), +); + +// emoji-picker-element ZWJ checks call Range#getBoundingClientRect (incomplete in jsdom). +if (typeof Range !== 'undefined' && typeof Range.prototype.getBoundingClientRect !== 'function') { + Range.prototype.getBoundingClientRect = emptyDomRect; +} +if (typeof Range !== 'undefined' && typeof Range.prototype.getClientRects !== 'function') { + Range.prototype.getClientRects = () => [] as unknown as DOMRectList; +} afterEach(() => { cleanup(); resetElectronAPIOutboxMock(); diff --git a/tsconfig.main.json b/tsconfig.main.json index 291063037..abc30a82f 100644 --- a/tsconfig.main.json +++ b/tsconfig.main.json @@ -15,7 +15,9 @@ "baseUrl": ".", "paths": { "@/*": ["src/*"] - } + }, + "allowImportingTsExtensions": true, + "noEmit": true }, "include": [ "src/main/**/*", @@ -23,7 +25,7 @@ "src/shared/**/*", "src/architecture/**/*", "src/main/types/**/*", - "vitest.harness.ts", + "vitest.harness.mts", "vitest.harness.test.ts" ], "exclude": ["node_modules", "dist"] diff --git a/vite.config.ts b/vite.config.mts similarity index 79% rename from vite.config.ts rename to vite.config.mts index 838c2fb51..7743aa3e7 100644 --- a/vite.config.ts +++ b/vite.config.mts @@ -7,7 +7,7 @@ import react from '@vitejs/plugin-react'; /** Emscripten glue fetches `orlp-ed25519.wasm` relative to the page; it is not inlined by Vite. */ const ORLP_WASM_NAME = 'orlp-ed25519.wasm'; const ORLP_WASM_SRC = path.resolve( - __dirname, + import.meta.dirname, `node_modules/@michaelhart/meshcore-decoder/lib/${ORLP_WASM_NAME}`, ); @@ -37,9 +37,9 @@ function meshcoreOrlpWasmPlugin(): import('vite').Plugin { if (!existsSync(ORLP_WASM_SRC)) return; const buf = readFileSync(ORLP_WASM_SRC); // Next to index.html (relative URL "orlp-ed25519.wasm" from document) - writeFileSync(path.resolve(__dirname, 'dist/renderer', ORLP_WASM_NAME), buf); + writeFileSync(path.resolve(import.meta.dirname, 'dist/renderer', ORLP_WASM_NAME), buf); // Some Emscripten builds resolve WASM relative to the JS chunk in assets/ - writeFileSync(path.resolve(__dirname, 'dist/renderer/assets', ORLP_WASM_NAME), buf); + writeFileSync(path.resolve(import.meta.dirname, 'dist/renderer/assets', ORLP_WASM_NAME), buf); }, }; } @@ -68,10 +68,10 @@ export default defineConfig({ // Electron's renderer already has process.type === "renderer"; this matches that. 'process.type': JSON.stringify('renderer'), }, - root: path.resolve(__dirname, 'src/renderer'), + root: path.resolve(import.meta.dirname, 'src/renderer'), base: './', build: { - outDir: path.resolve(__dirname, 'dist/renderer'), + outDir: path.resolve(import.meta.dirname, 'dist/renderer'), emptyOutDir: true, sourcemap: false, // Production: omit source maps (smaller bundle). For crash symbolication use @@ -109,22 +109,22 @@ export default defineConfig({ }, resolve: { alias: { - '@': path.resolve(__dirname, 'src'), + '@': path.resolve(import.meta.dirname, 'src'), // Node built-ins imported by transitive deps (e.g. @meshtastic/core 2.6.7 via // @meshtastic/transport-web-serial). Listing them as rollup externals emits bare // `import "os"` etc. which the browser rejects. Redirect to renderer-safe stubs instead. - fs: path.resolve(__dirname, 'src/renderer/shims/node-fs-stub.ts'), - os: path.resolve(__dirname, 'src/renderer/shims/node-os-stub.ts'), - path: path.resolve(__dirname, 'src/renderer/shims/node-path-stub.ts'), - util: path.resolve(__dirname, 'src/renderer/shims/node-util-stub.ts'), - stream: path.resolve(__dirname, 'src/renderer/shims/node-stream-stub.ts'), - child_process: path.resolve(__dirname, 'src/renderer/shims/node-child-process-stub.ts'), - net: path.resolve(__dirname, 'src/renderer/shims/node-net-stub.ts'), - events: path.resolve(__dirname, 'src/renderer/shims/node-events-stub.ts'), + fs: path.resolve(import.meta.dirname, 'src/renderer/shims/node-fs-stub.ts'), + os: path.resolve(import.meta.dirname, 'src/renderer/shims/node-os-stub.ts'), + path: path.resolve(import.meta.dirname, 'src/renderer/shims/node-path-stub.ts'), + util: path.resolve(import.meta.dirname, 'src/renderer/shims/node-util-stub.ts'), + stream: path.resolve(import.meta.dirname, 'src/renderer/shims/node-stream-stub.ts'), + child_process: path.resolve(import.meta.dirname, 'src/renderer/shims/node-child-process-stub.ts'), + net: path.resolve(import.meta.dirname, 'src/renderer/shims/node-net-stub.ts'), + events: path.resolve(import.meta.dirname, 'src/renderer/shims/node-events-stub.ts'), }, }, css: { - postcss: path.resolve(__dirname, 'postcss.config.cjs'), + postcss: path.resolve(import.meta.dirname, 'postcss.config.cjs'), }, server: { port: 5173, diff --git a/vitest.config.ts b/vitest.config.mts similarity index 96% rename from vitest.config.ts rename to vitest.config.mts index 51bb77dba..ab26b0bd3 100644 --- a/vitest.config.ts +++ b/vitest.config.mts @@ -1,7 +1,6 @@ import { readdirSync } from 'node:fs'; import os from 'node:os'; -import { dirname, join, relative, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { join, relative, resolve } from 'node:path'; import react from '@vitejs/plugin-react'; import { defineConfig } from 'vitest/config'; @@ -11,10 +10,9 @@ import { resolveVitestProjectMaxWorkers, VITEST_CORE_DEPS, VITEST_SERVER_INLINE_DEPS, -} from './vitest.harness'; +} from './vitest.harness.mts'; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const srcAlias = { '@': resolve(__dirname, 'src') }; +const srcAlias = { '@': resolve(import.meta.dirname, 'src') }; const cpuCount = os.cpus().length; /** Per-project CI shards skip global thresholds; merge job enforces them on combined coverage. */ @@ -179,7 +177,7 @@ const RENDERER_LOGIC_LIB_UI_FALLBACK = new Set( ); function collectRendererLibTestFiles(): string[] { - const libRoot = join(__dirname, 'src/renderer/lib'); + const libRoot = join(import.meta.dirname, 'src/renderer/lib'); const results: string[] = []; const walk = (dir: string): void => { for (const ent of readdirSync(dir, { withFileTypes: true })) { @@ -187,7 +185,7 @@ function collectRendererLibTestFiles(): string[] { if (ent.isDirectory()) { walk(path); } else if (ent.name.endsWith('.test.ts')) { - results.push(relative(__dirname, path).split('\\').join('/')); + results.push(relative(import.meta.dirname, path).split('\\').join('/')); } } }; @@ -235,7 +233,7 @@ export default defineConfig({ name: 'renderer-ui', globals: true, environment: 'jsdom', - setupFiles: [resolve(__dirname, 'src/renderer/vitest.setup.ts')], + setupFiles: [resolve(import.meta.dirname, 'src/renderer/vitest.setup.ts')], include: ['src/renderer/**/*.test.{ts,tsx}'], exclude: RENDERER_UI_EXCLUDE, pool: 'forks', diff --git a/vitest.harness.ts b/vitest.harness.mts similarity index 100% rename from vitest.harness.ts rename to vitest.harness.mts diff --git a/vitest.harness.test.ts b/vitest.harness.test.ts index 8e87abec5..f8954be40 100644 --- a/vitest.harness.test.ts +++ b/vitest.harness.test.ts @@ -10,7 +10,7 @@ import { resolveVitestProjectMaxWorkers, VITEST_CORE_DEPS, VITEST_SERVER_INLINE_DEPS, -} from './vitest.harness'; +} from './vitest.harness.mts'; describe('vitest.harness', () => { it('computeVitestMaxWorkers applies ratio and MIN_VITEST_WORKERS floor', () => {