diff --git a/packages/code-analyzer-core/test/utils.test.ts b/packages/code-analyzer-core/test/utils.test.ts index 0dd5cde7..9ee4bef2 100644 --- a/packages/code-analyzer-core/test/utils.test.ts +++ b/packages/code-analyzer-core/test/utils.test.ts @@ -1,4 +1,7 @@ -import {deepEquals} from "../src/utils"; +import {deepEquals, RealFileSystem, TempFolder} from "../src/utils"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; describe('deepEquals function tests', () => { describe('primitive type comparison tests', () => { @@ -151,4 +154,30 @@ describe('deepEquals function tests', () => { expect(deepEquals(obj1, obj2)).toBe(false); }); }); +}); + +describe('file system and temp folder utilities', () => { + it('RealFileSystem.rmSync removes a directory recursively (covers rmSync)', () => { + const realFs = new RealFileSystem(); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ca-core-rmSync-')); + const nested = path.join(dir, 'nested'); + fs.mkdirSync(nested); + fs.writeFileSync(path.join(nested, 'file.txt'), 'x'); + expect(fs.existsSync(dir)).toBe(true); + + realFs.rmSync(dir, {recursive: true, force: true}); + expect(fs.existsSync(dir)).toBe(false); + }); + + it('TempFolder.removeSyncIfNotKept removes root when not marked to keep (covers removeSyncIfNotKept)', async () => { + const temp = new TempFolder(); // uses RealFileSystem internally + const subPath = await temp.getPath('sub'); + const root = path.dirname(subPath); + fs.mkdirSync(subPath, {recursive: true}); + fs.writeFileSync(path.join(subPath, 'a.txt'), 'content'); + expect(fs.existsSync(root)).toBe(true); + + temp.removeSyncIfNotKept(); + expect(fs.existsSync(root)).toBe(false); + }); }); \ No newline at end of file diff --git a/packages/code-analyzer-eslint-engine/test/perf-eslint-run.test.ts b/packages/code-analyzer-eslint-engine/test/perf-eslint-run.test.ts new file mode 100644 index 00000000..75f63c88 --- /dev/null +++ b/packages/code-analyzer-eslint-engine/test/perf-eslint-run.test.ts @@ -0,0 +1,92 @@ +import {Engine, RuleDescription, Workspace} from "@salesforce/code-analyzer-engine-api"; +import {ESLintEnginePlugin} from "../src"; +import {DEFAULT_CONFIG, ESLintEngineConfig} from "../src/config"; +import {createDescribeOptions, createRunOptions} from "./test-helpers"; + +/** + * One-off performance probe for running ESLint rules (runRules). + * + * What this test does: + * - Instantiates the ESLint engine with the current base configuration. + * - Discovers rules (describeRules), then selects a subset to run (e.g., Recommended). + * - Runs the engine against the provided workspace (PERF_WS must be set). + * - Samples Node's resident set size (RSS) and prints a JSON summary: + * { + * "files_scanned": , + * "rules_run": , + * "run_ms": , + * "peak_rss_mb": , + * "violations": + * } + * + * How to run (skipped by default; opt-in with env var): + * ESLINT_ENGINE_PERF_RUN=true PERF_WS="/absolute/path/to/project" PERF_DISCOVER=true \ + * npm run test-typescript -- packages/code-analyzer-eslint-engine/test/perf-eslint-run.test.ts + * + * Optional env vars: + * - PERF_RULES_LIMIT=200 → cap the number of rules to run (keeps runtime stable) + * - PERF_DISCOVER=true → let ESLint auto-discover configs from PERF_WS (sets config_root to PERF_WS) + * - You can toggle: + * // disable_react_base_config: true, + * // disable_typescript_base_config: true, + * in the config below to attribute parser/plugin costs. + */ +const RUN = process.env.ESLINT_ENGINE_PERF_RUN === 'true'; +(RUN ? describe : describe.skip)('ESLint engine perf (runRules one-off)', () => { + it('measures runRules wall time and peak RSS', async () => { + const cfg: ESLintEngineConfig = { + ...DEFAULT_CONFIG, + // Toggle to isolate costs if desired: + disable_react_base_config: false, + disable_typescript_base_config: true, + config_root: __dirname, + auto_discover_eslint_config: process.env.PERF_DISCOVER === 'true', + }; + + const wsPath = process.env.PERF_WS; + if (!wsPath) { + throw new Error('PERF_WS env var must be set to an absolute path for runRules perf test.'); + } + const ws: Workspace = new Workspace('perf', [wsPath]); + if (process.env.PERF_DISCOVER === 'true') { + cfg.config_root = wsPath; + } + + const engine: Engine = await new ESLintEnginePlugin().createEngine('eslint', cfg); + + // Discover and select rules to run (Recommended by default), with an optional cap. + const discovered: RuleDescription[] = await engine.describeRules(createDescribeOptions(ws)); + const rulesToRun: string[] = discovered + .filter(r => r.tags.includes('Recommended')) + .slice(0, Number(process.env.PERF_RULES_LIMIT) || 200) + .map(r => r.name); + + const peak = {rss: 0}; + const sampler = setInterval(() => { + const m = process.memoryUsage(); + if (m.rss > peak.rss) peak.rss = m.rss; + }, 50); + + const mem0 = process.memoryUsage().rss; + const t0 = performance.now(); + const results = await engine.runRules(rulesToRun, createRunOptions(ws)); + const t1 = performance.now(); + const mem1 = process.memoryUsage().rss; + clearInterval(sampler); + + // Calculate files that produced violations (as a proxy for scanned footprint). + const filesScanned = Array.from(new Set(results.violations.map(v => v.codeLocations[0].file))).length; + + // eslint-disable-next-line no-console + console.log(JSON.stringify({ + files_scanned: filesScanned, + rules_run: rulesToRun.length, + run_ms: Math.round(t1 - t0), + peak_rss_mb: Math.round(Math.max(peak.rss, mem0, mem1) / (1024 * 1024)), + violations: results.violations.length + }, null, 2)); + + expect(rulesToRun.length).toBeGreaterThan(0); + }); +}); + diff --git a/packages/code-analyzer-eslint-engine/test/perf-eslint.test.ts b/packages/code-analyzer-eslint-engine/test/perf-eslint.test.ts new file mode 100644 index 00000000..a46c6578 --- /dev/null +++ b/packages/code-analyzer-eslint-engine/test/perf-eslint.test.ts @@ -0,0 +1,90 @@ +import {Engine, RuleDescription, Workspace} from "@salesforce/code-analyzer-engine-api"; +import {ESLintEnginePlugin} from "../src"; +import {DEFAULT_CONFIG, ESLintEngineConfig} from "../src/config"; +import {createDescribeOptions} from "./test-helpers"; +import * as path from "node:path"; + +/** + * One-off performance probe for the ESLint engine. + * + * What this test does: + * - Instantiates the ESLint engine with the current base configuration. + * - Calls engine.describeRules(), which exercises rule discovery (parsing configs, resolving plugins, building rule lists). + * - Samples Node's resident set size (RSS) periodically to estimate the peak memory use during describeRules(). + * - Emits a single JSON object to stdout with: + * { + * "rule_count": , + * "describe_ms": , + * "peak_rss_mb": + * } + * + * How to run (skipped by default; opt-in with env var): + * ESLINT_ENGINE_PERF=true npm run test-typescript -- packages/code-analyzer-eslint-engine/test/perf-eslint.test.ts + * + * ESLINT_ENGINE_PERF=true PERF_WS="/Users/arun.tyagi/projects/dreamhouse-sfdx" PERF_DISCOVER=true \ + * npm run test-typescript -- packages/code-analyzer-eslint-engine/test/perf-eslint.test.ts + * + * What to look for: + * - describe_ms: Use this to compare wall-time across changes. Lower is better. + * - peak_rss_mb: Track memory impact/regressions (e.g., when enabling additional parsers/plugins). + * - rule_count: Sanity check that you are comparing like-for-like runs (similar rule surface). + * + * How to isolate parser/plugin costs: + * - Set disable_react_base_config: true → measure baseline without React rules/plugins. + * - Set disable_typescript_base_config: true → measure without the TypeScript parser/rules. + * Run the test multiple times, toggling these flags, and compare the outputs. + * + * Notes: + * - This is a coarse probe (RSS sampling every 50ms). For deeper analysis, also try: + * node --cpu-prof --heap-prof ./node_modules/.bin/jest packages/code-analyzer-eslint-engine/test/perf-eslint.test.ts + * and inspect the generated profiles in Chrome DevTools. + */ +const RUN = process.env.ESLINT_ENGINE_PERF === 'true'; +(RUN ? describe : describe.skip)('ESLint engine perf (one-off)', () => { + it('measures describeRules wall time and peak RSS', async () => { + const config: ESLintEngineConfig = { + ...DEFAULT_CONFIG, + // Toggle these to isolate costs: + // disable_react_base_config: true, + // disable_typescript_base_config: true, + config_root: __dirname + }; + + // If a workspace path is provided (PERF_WS), analyze that project. + // Optionally allow ESLint to auto-discover configs from that project (PERF_DISCOVER=true). + const wsPath = process.env.PERF_WS; + const ws: Workspace | undefined = wsPath ? new Workspace('perf', [wsPath]) : undefined; + if (wsPath && process.env.PERF_DISCOVER === 'true') { + (config as ESLintEngineConfig).auto_discover_eslint_config = true; + (config as ESLintEngineConfig).config_root = wsPath; + } + + // Create engine with the chosen config flags + const engine: Engine = await new ESLintEnginePlugin().createEngine('eslint', config); + + // Track peak RSS during the measured operation with a light-weight sampler. + const peak = { rss: 0 }; + const sampler = setInterval(() => { + const m = process.memoryUsage(); + if (m.rss > peak.rss) peak.rss = m.rss; + }, 50); + + // Measure wall time of describeRules (rule discovery). + const mem0 = process.memoryUsage().rss; + const t0 = performance.now(); + const rules: RuleDescription[] = await engine.describeRules(createDescribeOptions(ws)); + const t1 = performance.now(); + const mem1 = process.memoryUsage().rss; + clearInterval(sampler); + + // eslint-disable-next-line no-console + console.log(JSON.stringify({ + rule_count: rules.length, + describe_ms: Math.round(t1 - t0), + peak_rss_mb: Math.round(Math.max(peak.rss, mem0, mem1) / (1024 * 1024)) + }, null, 2)); + + expect(rules.length).toBeGreaterThan(0); + }); +}); + diff --git a/packages/code-analyzer-eslint-engine/test/utils.test.ts b/packages/code-analyzer-eslint-engine/test/utils.test.ts index 40200017..6745146e 100644 --- a/packages/code-analyzer-eslint-engine/test/utils.test.ts +++ b/packages/code-analyzer-eslint-engine/test/utils.test.ts @@ -114,3 +114,72 @@ describe('Tests for the makeStringifiable utility function', () => { expect(makeStringifiable(obj)).toEqual({"date": {}, "regex": {}}); }); }); + +describe('utils: makeUnique deduplication and makeStringifiable serialization', () => { + describe('makeUnique removes duplicates and preserves order', () => { + it('removes duplicates while preserving order', () => { + expect(makeUnique(['a','b','a','c','b','d'])).toEqual(['a','b','c','d']); + expect(makeUnique([])).toEqual([]); + }); + }); + + describe('makeStringifiable handles edge cases and tracks paths', () => { + it('passes through primitives', () => { + expect(makeStringifiable('str')).toBe('str'); + expect(makeStringifiable(42)).toBe(42); + expect(makeStringifiable(true)).toBe(true); + expect(makeStringifiable(null)).toBe(null); + }); + + it('stringifies functions and symbols/bigints safely', () => { + expect(makeStringifiable(() => 1)).toBe('[Function]'); + // BigInt and Symbol should stringify to String(value) + // Using toString form because equality to Symbol(...) is not supported + expect(makeStringifiable(BigInt(10))).toBe('10'); + const s = Symbol('x'); + expect(makeStringifiable(s)).toBe(String(s)); + }); + + it('handles arrays with self references', () => { + const a: unknown[] = ['x']; + a.push(a); // self reference + const res = makeStringifiable(a) as unknown[]; + expect(res[0]).toBe('x'); + // second element becomes a marker with the first path where it was seen + expect(typeof res[1]).toBe('string'); + expect((res[1] as string)).toContain('[Exact same array as: '); // path marker + }); + + it('handles objects with self references', () => { + const o: Record = {a: 1}; + o.self = o; // cycle + const res = makeStringifiable(o) as Record; + expect(res.a).toBe(1); + expect(typeof res.self).toBe('string'); + expect((res.self as string)).toContain('[Exact same object as: '); // path marker + }); + + it('marks unserializable property access with placeholder', () => { + const o = Object.create(null) as { ok: number; bad: unknown }; + Object.defineProperty(o, 'ok', { value: 1, enumerable: true }); + Object.defineProperty(o, 'bad', { + enumerable: true, + get() { throw new Error('boom'); } + }); + const res = makeStringifiable(o) as Record; + expect(res.ok).toBe(1); + expect(res.bad).toBe('[Unserializable]'); + }); + + it('applies replacer with correct path propagation', () => { + const paths: string[] = []; + const v = { foo: { bar: [1,2] } }; + const out = makeStringifiable(v, (_val, p) => { paths.push(p); return _val; }); + expect(paths).toEqual(expect.arrayContaining([ + '', '.foo', '.foo.bar', '.foo.bar[0]', '.foo.bar[1]' + ])); + // structure preserved + expect((out as any).foo.bar[0]).toBe(1); + }); + }); +});