Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 11 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,13 @@ Electron: `src/main/` (Node, SQLite, BLE, MQTT), `src/preload/` (bridge), `src/r

Path alias `@/*` → `src/*` (see `tsconfig.json`).

| Boundary | Path | Role |
| -------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Main | `src/main/` | SQLite (`database.ts`, `db-compat.ts`), BLE (`noble-ble-manager.ts`), MQTT (`mqtt-manager.ts`, `meshcore-mqtt-adapter.ts`), logging (`log-service.ts`, `sanitize-log-message.ts`), IPC handlers (`index.ts` plus namespaced modules in `src/main/ipc/` — Reticulum, TAK, GPS), window, GPS, updater |
| Preload | `src/preload/` | `contextBridge` exposing namespaced `electronAPI` only; never expose `ipcRenderer` |
| Renderer | `src/renderer/` | React 19 + Vite + Zustand: `components/`, `hooks/`, `runtime/`, `stores/`, `lib/` (includes `lib/diagnostics/`, `lib/meshcore/`, `lib/radio/`, `lib/transport/`), `workers/` |
| Shared | `src/shared/` | IPC contracts (`electron-api.types.ts`), protocol-neutral helpers |
| Boundary | Path | Role |
| ------------ | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Main | `src/main/` | SQLite (`database.ts`, `db-compat.ts`), BLE (`noble-ble-manager.ts`), MQTT (`mqtt-manager.ts`, `meshcore-mqtt-adapter.ts`), logging (`log-service.ts`, `sanitize-log-message.ts`), IPC handlers (`index.ts` plus namespaced modules in `src/main/ipc/` — Reticulum, TAK, GPS), window, GPS, updater |
| Preload | `src/preload/` | `contextBridge` exposing namespaced `electronAPI` only; never expose `ipcRenderer` |
| Renderer | `src/renderer/` | React 19 + Vite + Zustand: `components/`, `hooks/`, `runtime/`, `stores/`, `lib/` (includes `lib/diagnostics/`, `lib/meshcore/`, `lib/radio/`, `lib/transport/`), `workers/` |
| Shared | `src/shared/` | IPC contracts (`electron-api.types.ts`), protocol-neutral helpers |
| Architecture | `src/architecture/` | Vitest source-policy registry (file-local invariants; prefer over new `check-*.mjs`) |

Entry points: `src/main/index.ts`, `src/preload/index.ts`, `src/renderer/main.tsx`, `src/renderer/App.tsx`.

Expand Down Expand Up @@ -88,20 +89,21 @@ Adding a cross-boundary feature:

## 5. Testing

- Renderer: jsdom (`src/renderer/**/*.test.{ts,tsx}`). Main: node (`src/main/**/*.test.ts`).
- Renderer: jsdom (`src/renderer/**/*.test.{ts,tsx}`). Main: node (`src/main/**/*.test.ts`, `src/architecture/**/*.test.ts`, `src/shared` / preload / scripts).
- **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 <rule-id> <reason>`. 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.
- 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).
- 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.
- Update `src/main/index.contract.test.ts` when CSP, build config, IPC limits, or log filters change.

### Accessibility / axe

- **Dev:** `@axe-core/react` runs in `pnpm run dev` (`src/renderer/main.tsx`); treat `serious` axe console output as a bug.
- **CI:** Use `vitest-axe` (`import { axe } from 'vitest-axe'`); assert `toHaveNoViolations()` on the rendered subtree.
- **Do not mock `themeColors` in component axe tests** — call `hydrateAxeThemeColors()` from `src/renderer/lib/a11yTestHelpers.ts` so color-contrast runs against real hex values (jsdom does not load Tailwind CSS).
- **Do not mock `themeColors` in component axe tests** — call `hydrateAxeThemeColors()` from `src/renderer/lib/a11yTestHelpers.ts` so color-contrast runs against real hex values (jsdom does not load Tailwind CSS). Enforced by source-policy rule `axe-tests-hydrate-theme-colors`.
- **When to add tests:** New or changed UI with custom foreground/background pairs (badges, pills, buttons)—especially `text-[10px]` / `text-xs` on saturated fills.
- **Theme tokens:** `readable-green` is for white-on-green fills; the default must pass **4.5:1** contrast with white (enforced in `src/renderer/lib/themeColors.test.ts`).
- **`animate-pulse`:** Never on the same element as small text with strict contrast fills. Use a separate `aria-hidden` decorative pulse layer; the text-bearing element stays fully opaque (see `ProtocolUnreadBadge.tsx`). Connection-status header pulses remain the documented exception.
Expand Down
75 changes: 54 additions & 21 deletions scripts/precommit-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ const ROOT = path.resolve(__dirname, '..');
const SOURCE_EXT_RE = /\.(?:[cm]?[jt]sx?)$/i;
const TEST_SIBLING_RE = /\.test\.(?:[cm]?[jt]sx?)$/i;

/** Central source-policy suite — always related when any TypeScript under src/ is staged. */
export const SOURCE_POLICY_TEST_PATH = 'src/architecture/sourcePolicy.test.ts';
const SRC_TS_PATH_RE = /^src\/.+\.(?:ts|tsx)$/;

const FORCE_FULL_PATTERNS = [
/^vitest\.config\./,
/^vitest\.harness(\.|$)/,
Expand Down Expand Up @@ -103,6 +107,37 @@ export function expandWithSiblingTests(stagedPaths, opts = {}) {
return [...out].sort();
}

/**
* Registry walker is not imported by production files, so `vitest related` would
* miss it. Append whenever any TypeScript under `src/` is in the related set.
* @param {string[]} relatedPaths
* @returns {string[]}
*/
export function appendSourcePolicyTestIfNeeded(relatedPaths) {
const normalized = relatedPaths.map((p) => p.replace(/\\/g, '/'));
const needsPolicy = normalized.some((p) => SRC_TS_PATH_RE.test(p));
if (!needsPolicy) return [...relatedPaths].sort();
if (normalized.includes(SOURCE_POLICY_TEST_PATH)) return [...normalized].sort();
return [...normalized, SOURCE_POLICY_TEST_PATH].sort();
}

/**
* @param {string} filePath
* @returns {boolean}
*/
function isMainProjectPath(filePath) {
const p = filePath.replace(/\\/g, '/');
return (
p.startsWith('src/main/') ||
p.startsWith('src/shared/') ||
p.startsWith('src/preload/') ||
p.startsWith('src/architecture/') ||
p.startsWith('scripts/') ||
p === 'vitest.harness.ts' ||
p === 'vitest.harness.test.ts'
);
}

/**
* Pick Vitest projects for a related-file set.
* Ambiguous / unknown paths fall back to all three projects.
Expand All @@ -114,38 +149,36 @@ export function pickProjects(relatedPaths) {

const normalized = relatedPaths.map((p) => p.replace(/\\/g, '/'));

const onlyMain = normalized.every(
(p) =>
p.startsWith('src/main/') ||
p.startsWith('src/shared/') ||
p.startsWith('src/preload/') ||
p.startsWith('scripts/') ||
p === 'vitest.harness.ts' ||
p === 'vitest.harness.test.ts',
);
const onlyMain = normalized.every((p) => isMainProjectPath(p));
if (onlyMain) return ['main'];

const onlyRendererLibOrStores = normalized.every(
(p) =>
p.startsWith('src/renderer/lib/') ||
p.startsWith('src/renderer/stores/') ||
p === 'src/renderer/locales/locale-quality.test.ts',
p === 'src/renderer/locales/locale-quality.test.ts' ||
p === SOURCE_POLICY_TEST_PATH,
);
if (onlyRendererLibOrStores) {
// Logic owns most lib tests; UI owns borderline lib exclusions — run both, skip main.
// Logic owns most lib tests; UI owns borderline lib exclusions — run both.
// Source-policy lives in the main project; include it when appended.
if (normalized.includes(SOURCE_POLICY_TEST_PATH)) {
return ['renderer-logic', 'renderer-ui', 'main'];
}
return ['renderer-logic', 'renderer-ui'];
}

const onlyRenderer = normalized.every((p) => p.startsWith('src/renderer/'));
if (onlyRenderer) return ['renderer-ui', 'renderer-logic'];

const hasMain = normalized.some(
(p) =>
p.startsWith('src/main/') ||
p.startsWith('src/shared/') ||
p.startsWith('src/preload/') ||
p.startsWith('scripts/'),
const onlyRendererOrPolicy = normalized.every(
(p) => p.startsWith('src/renderer/') || p === SOURCE_POLICY_TEST_PATH,
);
if (onlyRendererOrPolicy) {
if (normalized.includes(SOURCE_POLICY_TEST_PATH)) {
return ['renderer-ui', 'renderer-logic', 'main'];
}
return ['renderer-ui', 'renderer-logic'];
}

const hasMain = normalized.some((p) => isMainProjectPath(p));
const hasRenderer = normalized.some((p) => p.startsWith('src/renderer/'));
if (hasMain && hasRenderer) return ['renderer-ui', 'renderer-logic', 'main'];
if (hasMain) return ['main'];
Expand All @@ -167,7 +200,7 @@ export function planPrecommitTests(stagedPaths) {
};
}

const relatedPaths = expandWithSiblingTests(stagedPaths);
const relatedPaths = appendSourcePolicyTestIfNeeded(expandWithSiblingTests(stagedPaths));
if (relatedPaths.length === 0) {
return { mode: 'skip', relatedPaths: [], projects: [] };
}
Expand Down
12 changes: 12 additions & 0 deletions scripts/precommit-tests.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ describe('precommit-tests related planning', () => {
expect(plan.mode).toBe('related');
expect(plan.projects).toEqual(['main']);
expect(plan.relatedPaths).toContain('src/main/foo.ts');
expect(plan.relatedPaths).toContain('src/architecture/sourcePolicy.test.ts');
});

it('appends source-policy test for renderer staged files and includes main project', () => {
const plan = planPrecommitTests(['src/renderer/components/ChatPanel.tsx']);
expect(plan.mode).toBe('related');
expect(plan.relatedPaths).toContain('src/architecture/sourcePolicy.test.ts');
expect(plan.projects).toContain('main');
});

it('picks main for architecture paths', () => {
expect(pickProjects(['src/architecture/sourcePolicy.test.ts'])).toEqual(['main']);
});
});

Expand Down
163 changes: 163 additions & 0 deletions src/architecture/sourcePolicy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { describe, expect, it } from 'vitest';

import { collectSourcePolicyViolations, matchSimpleGlob } from './sourcePolicy';
import type { SourcePolicyRule } from './sourcePolicyRules';
import { SOURCE_POLICY_RULES } from './sourcePolicyRules';

describe('matchSimpleGlob', () => {
it('matches ** and brace extensions', () => {
expect(matchSimpleGlob('src/renderer/foo.test.tsx', 'src/renderer/**/*.test.tsx')).toBe(true);
expect(matchSimpleGlob('src/main/chatExportFormat.ts', 'src/main/chatExportFormat.ts')).toBe(
true,
);
expect(matchSimpleGlob('src/a/b.ts', 'src/**/*.{ts,tsx}')).toBe(true);
expect(matchSimpleGlob('src/a/b.tsx', 'src/**/*.{ts,tsx}')).toBe(true);
expect(matchSimpleGlob('src/a/b.js', 'src/**/*.{ts,tsx}')).toBe(false);
});
});

describe('collectSourcePolicyViolations (fixtures)', () => {
it('reports forbid, honors line suppress, and require suppress', () => {
const root = mkdtempSync(join(tmpdir(), 'source-policy-'));
try {
const dir = join(root, 'src', 'fixture');
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, 'bad.ts'),
[
'const x = FORBIDDEN_CALL();',
'const y = FORBIDDEN_CALL(); // source-policy-ok demo-forbid intentional',
'',
].join('\n'),
'utf-8',
);
writeFileSync(join(dir, 'missing.ts'), 'export const ok = 1;\n', 'utf-8');
writeFileSync(
join(dir, 'missing-ok.ts'),
'// source-policy-ok demo-require documented skip\nexport const ok = 1;\n',
'utf-8',
);

const rules: SourcePolicyRule[] = [
{
id: 'demo-forbid',
include: ['src/fixture/bad.ts'],
forbid: /FORBIDDEN_CALL\s*\(/,
message: 'no forbidden call',
},
{
id: 'demo-require',
include: ['src/fixture/missing.ts', 'src/fixture/missing-ok.ts'],
require: /REQUIRED_TOKEN/,
message: 'must have token',
},
];

const violations = collectSourcePolicyViolations({ root, rules });
expect(violations).toEqual(
expect.arrayContaining([
expect.objectContaining({
ruleId: 'demo-forbid',
file: 'src/fixture/bad.ts',
line: 1,
}),
expect.objectContaining({
ruleId: 'demo-require',
file: 'src/fixture/missing.ts',
}),
]),
);
expect(violations.some((v) => v.file === 'src/fixture/missing-ok.ts')).toBe(false);
expect(
violations.filter((v) => v.ruleId === 'demo-forbid' && v.file === 'src/fixture/bad.ts'),
).toHaveLength(1);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

it('applies when+require only when when matches', () => {
const root = mkdtempSync(join(tmpdir(), 'source-policy-when-'));
try {
const dir = join(root, 'src', 'fixture');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'with-axe.tsx'), 'await axe(container);\n', 'utf-8');
writeFileSync(join(dir, 'no-axe.tsx'), 'export const x = 1;\n', 'utf-8');

const rules: SourcePolicyRule[] = [
{
id: 'demo-axe',
include: ['src/fixture/*.tsx'],
when: /\baxe\s*\(/,
require: /hydrateAxeThemeColors/,
message: 'hydrate required',
},
];

const violations = collectSourcePolicyViolations({ root, rules });
expect(violations).toEqual([
expect.objectContaining({ ruleId: 'demo-axe', file: 'src/fixture/with-axe.tsx' }),
]);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

it('advances past zero-length forbid matches even when suppressed', () => {
const root = mkdtempSync(join(tmpdir(), 'source-policy-zerolen-'));
try {
const dir = join(root, 'src', 'fixture');
mkdirSync(dir, { recursive: true });
// Zero-width lookbehind-style /(?=a)/g matches empty string before each 'a'.
writeFileSync(
join(dir, 'zero.ts'),
['a // source-policy-ok demo-zerolen intentional', 'a', ''].join('\n'),
'utf-8',
);

const rules: SourcePolicyRule[] = [
{
id: 'demo-zerolen',
include: ['src/fixture/zero.ts'],
forbid: /(?=a)/g,
message: 'zero-length forbid',
},
];

const violations = collectSourcePolicyViolations({ root, rules });
expect(violations).toEqual([
expect.objectContaining({
ruleId: 'demo-zerolen',
file: 'src/fixture/zero.ts',
line: 2,
}),
]);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});

describe('SOURCE_POLICY_RULES (repo)', () => {
it('has unique rule ids and the three starter rules', () => {
const ids = SOURCE_POLICY_RULES.map((r) => r.id);
expect(new Set(ids).size).toBe(ids.length);
expect(ids).toEqual(
expect.arrayContaining([
'runtime-tests-use-loadRuntimeSource',
'chat-export-incremental-cap',
'axe-tests-hydrate-theme-colors',
]),
);
});

/** Repo-wide gate: every `SOURCE_POLICY_RULES` include under src/ stays clean. */
it('reports no violations against the current tree', () => {
const violations = collectSourcePolicyViolations();
expect(violations).toEqual([]);
});
});
Loading