Skip to content

test: add comprehensive unit tests for tech-stack-detector#226

Closed
nikolasdehor wants to merge 1 commit intoSynkraAI:mainfrom
nikolasdehor:test/tech-stack-detector-coverage
Closed

test: add comprehensive unit tests for tech-stack-detector#226
nikolasdehor wants to merge 1 commit intoSynkraAI:mainfrom
nikolasdehor:test/tech-stack-detector-coverage

Conversation

@nikolasdehor
Copy link
Contributor

@nikolasdehor nikolasdehor commented Feb 18, 2026

Summary

  • Add 74 unit tests for .aios-core/core/orchestration/tech-stack-detector.js (599 lines)
  • Covers all 12 private methods and 1 static method
  • Tests detection of 6 database types, 6 frontend frameworks, 4 backend frameworks
  • Mocks fs-extra for deterministic file system testing

Test Coverage Highlights

Area Tests Description
Database detection 9 supabase, prisma, pg, mongodb, mysql, sqlite, RLS, env vars
Frontend detection 15 react, vue, angular, svelte, next.js, nuxt, build tools, styling, component libs
Backend detection 7 express, fastify, nestjs, hono, edge functions, API routes
TypeScript detection 3 dependency, tsconfig, fallback
Test framework detection 4 jest, vitest, test directories
Phase computation 6 conditional phase inclusion
Confidence scoring 8 base score, increments, 100 cap
Summary output 8 human-readable formatting
Package.json caching 3 cache behavior, error handling

Closes #225

Test plan

  • All 74 tests pass with npx jest tests/core/orchestration/tech-stack-detector.test.js
  • No external dependencies (all I/O mocked)
  • Tests run in < 1s

cc @Pedrovaleriolopez

Summary by CodeRabbit

  • Tests
    • Added a comprehensive test suite for TechStackDetector covering database, frontend, backend, TypeScript, and testing-tool detection across varied project configurations, plus caching behavior and error handling for missing/corrupt manifests.

Copilot AI review requested due to automatic review settings February 18, 2026 18:32
@vercel
Copy link

vercel bot commented Feb 18, 2026

@nikolasdehor is attempting to deploy a commit to the Pedro Valério Lopez's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link

coderabbitai bot commented Feb 18, 2026

No actionable comments were generated in the recent review. 🎉


Walkthrough

Adds a comprehensive unit test suite for TechStackDetector exercising constructor behavior, detection helpers (databases, frontend, backend, TypeScript, tests), utility methods, caching, and error handling via mocked filesystem interactions. No production code or public API changes.

Changes

Cohort / File(s) Summary
Tech Stack Detector Test Suite
tests/core/orchestration/tech-stack-detector.test.js
Adds ~70+ unit tests covering constructor, integration detect flow, private detection helpers (_detectDatabase, _detectFrontend, _detectBackend, _detectTypeScript, _detectTests), utility methods (_computeApplicablePhases, _calculateConfidence, getSummary, _loadPackageJson), caching behavior, and error handling. Heavily mocks fs-extra and package.json/tsconfig/env scenarios.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Suggested labels

tests, core

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding comprehensive unit tests for the tech-stack-detector module.
Linked Issues check ✅ Passed The pull request meets all coding requirements from issue #225: 74 tests covering database detection, frontend frameworks, backend frameworks, TypeScript, test frameworks, phases, confidence scoring, and package.json caching with mocked fs-extra.
Out of Scope Changes check ✅ Passed All changes are strictly test-related additions to a single test file with no modifications to the core tech-stack-detector module, maintaining tight scope alignment with the linked issue.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
tests/core/orchestration/tech-stack-detector.test.js (3)

22-27: Use jest.resetAllMocks() (or restore all four mocks in beforeEach) to prevent stale implementation bleed.

jest.clearAllMocks() only wipes call history — it does not reset mockImplementation or mockResolvedValue overrides. Right now, fs.readJson, fs.readdir, and fs.readFile silently retain whatever implementation the previous test left behind. Only pathExists is explicitly patched back.

Current bleed path: the RLS test (line 192) writes fs.readdir.mockResolvedValue(['001_init.sql']) and the .tsx test (line 341) writes ['App.tsx', 'index.ts']; both values persist untouched into every subsequent describe block that never touches readdir.

♻️ Proposed fix
  beforeEach(() => {
-   jest.clearAllMocks();
+   jest.resetAllMocks();
    detector = new TechStackDetector(ROOT);
-   // Default: no package.json
-   fs.pathExists.mockResolvedValue(false);
+   // Restore factory defaults after resetAllMocks()
+   fs.pathExists.mockResolvedValue(false);
+   fs.readJson.mockResolvedValue(null);
+   fs.readdir.mockResolvedValue([]);
+   fs.readFile.mockResolvedValue('');
  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/core/orchestration/tech-stack-detector.test.js` around lines 22 - 27,
Replace jest.clearAllMocks() with jest.resetAllMocks() (or explicitly restore
mocks) in the beforeEach that constructs TechStackDetector so mock
implementations from previous tests (e.g., fs.readJson, fs.readdir, fs.readFile)
are reset; ensure fs.pathExists remains mocked to false as currently done. This
targets the beforeEach block that sets up detector = new TechStackDetector(ROOT)
and currently calls jest.clearAllMocks(), and fixes bleed from tests setting
fs.readdir.mockResolvedValue(...) and similar.

42-49: p.includes(key) substring matching is ambiguous — prefer exact path comparison.

For example, mockPaths({ 'api': true }) will also return true for /test/project/pages/api, /test/project/someapi, etc. Whether those false positives matter depends entirely on which paths the implementation checks, making the test's fidelity fragile. Also, if two keys match the same resolved path (e.g., { 'package.json': false, 'supabase': true } against /supabase/package.json), the result is decided by key insertion order rather than intent.

♻️ Proposed fix — exact path matching
  function mockPaths(pathMap) {
    fs.pathExists.mockImplementation(async (p) => {
-     for (const [key, val] of Object.entries(pathMap)) {
-       if (p.includes(key)) return val;
-     }
-     return false;
+     const resolved = Object.fromEntries(
+       Object.entries(pathMap).map(([key, val]) => [path.join(ROOT, key), val])
+     );
+     return resolved[p] ?? false;
    });
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/core/orchestration/tech-stack-detector.test.js` around lines 42 - 49,
The mockPaths helper currently uses substring matching (p.includes(key)) which
causes false positives; update mockPaths to perform exact path comparisons by
resolving/normalizing both the incoming path and each key (use path.resolve or
path.normalize) and then compare for strict equality when implementing
fs.pathExists.mockImplementation in mockPaths; ensure keys in pathMap are
resolved once (inside mockPaths) so lookups use resolvedKey === resolvedP to
decide true/false rather than substring matching.

274-288: Build-tool tests omit hasFrontend assertion and place tools in the wrong dependency bucket.

Two related issues:

  1. Neither the vite nor webpack test asserts profile.hasFrontend. If the implementation does set hasFrontend on build-tool-only detection, the behavior is unverified. If it intentionally does not, adding a toBe(false) assertion makes that contract explicit and prevents silent regressions.

  2. vite and webpack are passed as runtime dependencies (first argument to mockPackageJson), not devDependencies. These are always dev tools. If the production code ever narrows its lookup to devDependencies only, these tests would give a false green. Move them to the second argument.

♻️ Proposed fix
-     it('should detect vite build tool', async () => {
-       mockPackageJson({ vite: '^5.0.0' });
+     it('should detect vite build tool', async () => {
+       mockPackageJson({}, { vite: '^5.0.0' });

        const profile = detector._createEmptyProfile();
        await detector._detectFrontend(profile);
        expect(profile.frontend.buildTool).toBe('vite');
+       expect(profile.hasFrontend).toBe(false); // or true — assert the actual contract
      });

-     it('should detect webpack build tool', async () => {
-       mockPackageJson({ webpack: '^5.0.0' });
+     it('should detect webpack build tool', async () => {
+       mockPackageJson({}, { webpack: '^5.0.0' });

        const profile = detector._createEmptyProfile();
        await detector._detectFrontend(profile);
        expect(profile.frontend.buildTool).toBe('webpack');
+       expect(profile.hasFrontend).toBe(false); // or true — assert the actual contract
      });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/core/orchestration/tech-stack-detector.test.js` around lines 274 - 288,
Update the two build-tool tests to assert the frontend presence and to mock the
tools as devDependencies: call mockPackageJson with vite/webpack in the second
argument (devDependencies) instead of the first, then after creating profile via
detector._createEmptyProfile() and running detector._detectFrontend(profile) add
an assertion on profile.hasFrontend (explicitly expect(false) if the intended
contract is that build-tool-only detection does not mark hasFrontend) and keep
the existing expect(profile.frontend.buildTool).toBe('vite'/'webpack') checks;
this ensures the tests reflect dev-only tooling and verify hasFrontend behavior
for detector._detectFrontend.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@tests/core/orchestration/tech-stack-detector.test.js`:
- Around line 22-27: Replace jest.clearAllMocks() with jest.resetAllMocks() (or
explicitly restore mocks) in the beforeEach that constructs TechStackDetector so
mock implementations from previous tests (e.g., fs.readJson, fs.readdir,
fs.readFile) are reset; ensure fs.pathExists remains mocked to false as
currently done. This targets the beforeEach block that sets up detector = new
TechStackDetector(ROOT) and currently calls jest.clearAllMocks(), and fixes
bleed from tests setting fs.readdir.mockResolvedValue(...) and similar.
- Around line 42-49: The mockPaths helper currently uses substring matching
(p.includes(key)) which causes false positives; update mockPaths to perform
exact path comparisons by resolving/normalizing both the incoming path and each
key (use path.resolve or path.normalize) and then compare for strict equality
when implementing fs.pathExists.mockImplementation in mockPaths; ensure keys in
pathMap are resolved once (inside mockPaths) so lookups use resolvedKey ===
resolvedP to decide true/false rather than substring matching.
- Around line 274-288: Update the two build-tool tests to assert the frontend
presence and to mock the tools as devDependencies: call mockPackageJson with
vite/webpack in the second argument (devDependencies) instead of the first, then
after creating profile via detector._createEmptyProfile() and running
detector._detectFrontend(profile) add an assertion on profile.hasFrontend
(explicitly expect(false) if the intended contract is that build-tool-only
detection does not mark hasFrontend) and keep the existing
expect(profile.frontend.buildTool).toBe('vite'/'webpack') checks; this ensures
the tests reflect dev-only tooling and verify hasFrontend behavior for
detector._detectFrontend.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds comprehensive unit test coverage for the TechStackDetector class, a critical deterministic module responsible for detecting project technology stacks (database, frontend, backend, TypeScript, and test frameworks) before workflow execution. The tests properly mock the fs-extra module to ensure deterministic behavior and fast execution.

Changes:

  • Add 73 unit tests covering all major detection methods and edge cases
  • Implement mock helpers for fs-extra operations (pathExists, readJson, readdir, readFile)
  • Test database detection for 6 types (supabase, prisma, postgresql, mongodb, mysql, sqlite)
  • Test frontend detection for 6 frameworks and their associated build tools/styling
  • Test backend, TypeScript, and test framework detection
  • Test phase computation, confidence scoring, and summary formatting

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

const profile = detector._createEmptyProfile();
await detector._detectDatabase(profile);
expect(profile.hasDatabase).toBe(true);
expect(profile.database.type).toBe('postgresql');
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test doesn't verify that profile.database.hasSchema is set to true when a prisma/schema.prisma file exists. According to the implementation (line 229), hasSchema should be true when schema.prisma exists. Add an assertion: expect(profile.database.hasSchema).toBe(true);

Suggested change
expect(profile.database.type).toBe('postgresql');
expect(profile.database.type).toBe('postgresql');
expect(profile.database.hasSchema).toBe(true);

Copilot uses AI. Check for mistakes.
fs.readFile.mockResolvedValue('CREATE TABLE t(); ENABLE ROW LEVEL SECURITY;');

const profile = detector._createEmptyProfile();
await detector._detectDatabase(profile);
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test doesn't verify that hasMigrations is set to true. According to the implementation (lines 196-198), when migrations directory exists, both hasMigrations and hasSchema should be set to true. Add assertions for these fields to ensure complete test coverage.

Suggested change
await detector._detectDatabase(profile);
await detector._detectDatabase(profile);
expect(profile.database.hasMigrations).toBe(true);
expect(profile.database.hasSchema).toBe(true);

Copilot uses AI. Check for mistakes.
await detector._detectFrontend(profile);
expect(profile.frontend.buildTool).toBe('webpack');
});

Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation (lines 338-341) also checks for 'esbuild' and 'parcel' build tools, but tests only cover 'vite' and 'webpack'. Add test cases for esbuild and parcel to achieve complete coverage of the build tool detection logic.

Suggested change
it('should detect esbuild build tool', async () => {
mockPackageJson({ esbuild: '^0.20.0' });
const profile = detector._createEmptyProfile();
await detector._detectFrontend(profile);
expect(profile.frontend.buildTool).toBe('esbuild');
});
it('should detect parcel build tool', async () => {
mockPackageJson({ parcel: '^2.0.0' });
const profile = detector._createEmptyProfile();
await detector._detectFrontend(profile);
expect(profile.frontend.buildTool).toBe('parcel');
});

Copilot uses AI. Check for mistakes.
const profile = detector._createEmptyProfile();
await detector._detectBackend(profile);
expect(profile.backend.hasAPI).toBe(true);
});
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation checks for multiple backend API paths including 'src/api' and 'app/api' (Next.js App Router) as shown in lines 427-432, but tests only cover the 'api' and 'pages/api' directories. Add tests for 'src/api' and 'app/api' to ensure all API path detection logic is covered.

Suggested change
});
});
it('should detect API routes from src/api (Next.js src structure)', async () => {
mockPaths({ 'package.json': false, 'src/api': true });
const profile = detector._createEmptyProfile();
await detector._detectBackend(profile);
expect(profile.backend.hasAPI).toBe(true);
});
it('should detect API routes from app/api (Next.js App Router)', async () => {
mockPaths({ 'package.json': false, 'app/api': true });
const profile = detector._createEmptyProfile();
await detector._detectBackend(profile);
expect(profile.backend.hasAPI).toBe(true);
});

Copilot uses AI. Check for mistakes.
const profile = detector._createEmptyProfile();
await detector._detectTests(profile);
expect(profile.hasTests).toBe(true);
});
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation checks for additional test directories: 'test' (singular) and 'src/tests' (lines 489-494), but tests only cover 'tests' and 'tests'. Add test cases for these other directory names to ensure complete coverage.

Suggested change
});
});
it('should detect from test directory', async () => {
mockPaths({ 'package.json': false, 'test': true });
const profile = detector._createEmptyProfile();
await detector._detectTests(profile);
expect(profile.hasTests).toBe(true);
});
it('should detect from src/__tests__ directory', async () => {
mockPaths({ 'package.json': false, 'src/__tests__': true });
const profile = detector._createEmptyProfile();
await detector._detectTests(profile);
expect(profile.hasTests).toBe(true);
});

Copilot uses AI. Check for mistakes.
Comment on lines +122 to +215
// ─── _detectDatabase ──────────────────────────────────────────

describe('_detectDatabase', () => {
it('should detect supabase from directory', async () => {
mockPaths({ 'package.json': true, 'supabase': true });
fs.readJson.mockResolvedValue({ dependencies: {}, devDependencies: {} });

const profile = detector._createEmptyProfile();
await detector._detectDatabase(profile);
expect(profile.hasDatabase).toBe(true);
expect(profile.database.type).toBe('supabase');
});

it('should detect supabase from dependency', async () => {
mockPackageJson({ '@supabase/supabase-js': '^2.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectDatabase(profile);
expect(profile.hasDatabase).toBe(true);
expect(profile.database.type).toBe('supabase');
});

it('should detect prisma/postgresql from directory', async () => {
mockPaths({ 'package.json': true, 'prisma': true, 'schema.prisma': true });
fs.readJson.mockResolvedValue({ dependencies: {}, devDependencies: {} });

const profile = detector._createEmptyProfile();
await detector._detectDatabase(profile);
expect(profile.hasDatabase).toBe(true);
expect(profile.database.type).toBe('postgresql');
});

it('should detect postgresql from pg dependency', async () => {
mockPackageJson({ pg: '^8.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectDatabase(profile);
expect(profile.hasDatabase).toBe(true);
expect(profile.database.type).toBe('postgresql');
});

it('should detect mongodb from mongoose dependency', async () => {
mockPackageJson({ mongoose: '^7.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectDatabase(profile);
expect(profile.hasDatabase).toBe(true);
expect(profile.database.type).toBe('mongodb');
});

it('should detect mysql from mysql2 dependency', async () => {
mockPackageJson({ mysql2: '^3.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectDatabase(profile);
expect(profile.hasDatabase).toBe(true);
expect(profile.database.type).toBe('mysql');
});

it('should detect sqlite from better-sqlite3', async () => {
mockPackageJson({ 'better-sqlite3': '^9.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectDatabase(profile);
expect(profile.hasDatabase).toBe(true);
expect(profile.database.type).toBe('sqlite');
});

it('should detect RLS from migration SQL', async () => {
mockPaths({ 'package.json': false, 'supabase': true, 'migrations': true });
fs.readdir.mockResolvedValue(['001_init.sql']);
fs.readFile.mockResolvedValue('CREATE TABLE t(); ENABLE ROW LEVEL SECURITY;');

const profile = detector._createEmptyProfile();
await detector._detectDatabase(profile);
expect(profile.database.hasRLS).toBe(true);
});

it('should detect env vars from .env files', async () => {
mockPaths({ 'package.json': false, '.env': true });
fs.readFile.mockResolvedValue('DATABASE_URL=postgres://localhost/db');

const profile = detector._createEmptyProfile();
await detector._detectDatabase(profile);
expect(profile.database.envVarsConfigured).toBe(true);
});

it('should not set database when no indicators found', async () => {
const profile = detector._createEmptyProfile();
await detector._detectDatabase(profile);
expect(profile.hasDatabase).toBe(false);
expect(profile.database.type).toBeNull();
});
});
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation handles file system errors gracefully with try-catch blocks (e.g., when reading migration files or env files), but there are no tests that verify error handling behavior. Consider adding test cases where fs.readFile or fs.readdir throw errors to ensure the detector continues functioning and doesn't crash when encountering file system errors.

Copilot uses AI. Check for mistakes.
await detector._detectFrontend(profile);
expect(profile.frontend.componentLibrary).toBe('mui');
});

Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation (lines 359-363) also checks for '@material-ui/core', 'chakra-ui/react', and 'antd' component libraries, but tests only cover 'shadcn' and 'mui'. Add test cases for Chakra UI and Ant Design to ensure complete coverage.

Suggested change
it('should detect Chakra UI component library', async () => {
mockPackageJson({ 'chakra-ui/react': '^2.0.0' });
const profile = detector._createEmptyProfile();
await detector._detectFrontend(profile);
expect(profile.frontend.componentLibrary).toBe('chakra');
});
it('should detect Ant Design component library', async () => {
mockPackageJson({ antd: '^5.0.0' });
const profile = detector._createEmptyProfile();
await detector._detectFrontend(profile);
expect(profile.frontend.componentLibrary).toBe('antd');
});

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +691
/**
* Tests for TechStackDetector
* @see .aios-core/core/orchestration/tech-stack-detector.js
*/

const path = require('path');

jest.mock('fs-extra', () => ({
pathExists: jest.fn().mockResolvedValue(false),
readJson: jest.fn().mockResolvedValue(null),
readdir: jest.fn().mockResolvedValue([]),
readFile: jest.fn().mockResolvedValue(''),
}));

const fs = require('fs-extra');
const TechStackDetector = require('../../../.aios-core/core/orchestration/tech-stack-detector');

describe('TechStackDetector', () => {
let detector;
const ROOT = '/test/project';

beforeEach(() => {
jest.clearAllMocks();
detector = new TechStackDetector(ROOT);
// Default: no package.json
fs.pathExists.mockResolvedValue(false);
});

// Helper to mock package.json with specific deps
function mockPackageJson(deps = {}, devDeps = {}) {
fs.pathExists.mockImplementation(async (p) => {
if (p === path.join(ROOT, 'package.json')) return true;
return false;
});
fs.readJson.mockResolvedValue({
dependencies: deps,
devDependencies: devDeps,
});
}

// Helper to mock pathExists for specific paths
function mockPaths(pathMap) {
fs.pathExists.mockImplementation(async (p) => {
for (const [key, val] of Object.entries(pathMap)) {
if (p.includes(key)) return val;
}
return false;
});
}

// ─── Constructor ───────────────────────────────────────────────

describe('constructor', () => {
it('should store project root', () => {
expect(detector.projectRoot).toBe(ROOT);
});

it('should initialize package.json cache as null', () => {
expect(detector._packageJsonCache).toBeNull();
});
});

// ─── _createEmptyProfile ──────────────────────────────────────

describe('_createEmptyProfile', () => {
it('should return profile with all flags false', () => {
const profile = detector._createEmptyProfile();
expect(profile.hasDatabase).toBe(false);
expect(profile.hasFrontend).toBe(false);
expect(profile.hasBackend).toBe(false);
expect(profile.hasTypeScript).toBe(false);
expect(profile.hasTests).toBe(false);
});

it('should return profile with null database type', () => {
const profile = detector._createEmptyProfile();
expect(profile.database.type).toBeNull();
});

it('should return profile with empty applicablePhases', () => {
const profile = detector._createEmptyProfile();
expect(profile.applicablePhases).toEqual([]);
});

it('should return confidence 0', () => {
const profile = detector._createEmptyProfile();
expect(profile.confidence).toBe(0);
});
});

// ─── detect (integration) ────────────────────────────────────

describe('detect', () => {
it('should return a complete profile', async () => {
const profile = await detector.detect();
expect(profile).toHaveProperty('hasDatabase');
expect(profile).toHaveProperty('hasFrontend');
expect(profile).toHaveProperty('hasBackend');
expect(profile).toHaveProperty('hasTypeScript');
expect(profile).toHaveProperty('hasTests');
expect(profile).toHaveProperty('applicablePhases');
expect(profile).toHaveProperty('confidence');
expect(profile).toHaveProperty('detectedAt');
});

it('should set detectedAt as ISO timestamp', async () => {
const profile = await detector.detect();
expect(profile.detectedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});

it('should always include phase 1 in applicable phases', async () => {
const profile = await detector.detect();
expect(profile.applicablePhases).toContain(1);
});

it('should have base confidence of 50 with no detections', async () => {
const profile = await detector.detect();
expect(profile.confidence).toBe(50);
});
});

// ─── _detectDatabase ──────────────────────────────────────────

describe('_detectDatabase', () => {
it('should detect supabase from directory', async () => {
mockPaths({ 'package.json': true, 'supabase': true });
fs.readJson.mockResolvedValue({ dependencies: {}, devDependencies: {} });

const profile = detector._createEmptyProfile();
await detector._detectDatabase(profile);
expect(profile.hasDatabase).toBe(true);
expect(profile.database.type).toBe('supabase');
});

it('should detect supabase from dependency', async () => {
mockPackageJson({ '@supabase/supabase-js': '^2.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectDatabase(profile);
expect(profile.hasDatabase).toBe(true);
expect(profile.database.type).toBe('supabase');
});

it('should detect prisma/postgresql from directory', async () => {
mockPaths({ 'package.json': true, 'prisma': true, 'schema.prisma': true });
fs.readJson.mockResolvedValue({ dependencies: {}, devDependencies: {} });

const profile = detector._createEmptyProfile();
await detector._detectDatabase(profile);
expect(profile.hasDatabase).toBe(true);
expect(profile.database.type).toBe('postgresql');
});

it('should detect postgresql from pg dependency', async () => {
mockPackageJson({ pg: '^8.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectDatabase(profile);
expect(profile.hasDatabase).toBe(true);
expect(profile.database.type).toBe('postgresql');
});

it('should detect mongodb from mongoose dependency', async () => {
mockPackageJson({ mongoose: '^7.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectDatabase(profile);
expect(profile.hasDatabase).toBe(true);
expect(profile.database.type).toBe('mongodb');
});

it('should detect mysql from mysql2 dependency', async () => {
mockPackageJson({ mysql2: '^3.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectDatabase(profile);
expect(profile.hasDatabase).toBe(true);
expect(profile.database.type).toBe('mysql');
});

it('should detect sqlite from better-sqlite3', async () => {
mockPackageJson({ 'better-sqlite3': '^9.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectDatabase(profile);
expect(profile.hasDatabase).toBe(true);
expect(profile.database.type).toBe('sqlite');
});

it('should detect RLS from migration SQL', async () => {
mockPaths({ 'package.json': false, 'supabase': true, 'migrations': true });
fs.readdir.mockResolvedValue(['001_init.sql']);
fs.readFile.mockResolvedValue('CREATE TABLE t(); ENABLE ROW LEVEL SECURITY;');

const profile = detector._createEmptyProfile();
await detector._detectDatabase(profile);
expect(profile.database.hasRLS).toBe(true);
});

it('should detect env vars from .env files', async () => {
mockPaths({ 'package.json': false, '.env': true });
fs.readFile.mockResolvedValue('DATABASE_URL=postgres://localhost/db');

const profile = detector._createEmptyProfile();
await detector._detectDatabase(profile);
expect(profile.database.envVarsConfigured).toBe(true);
});

it('should not set database when no indicators found', async () => {
const profile = detector._createEmptyProfile();
await detector._detectDatabase(profile);
expect(profile.hasDatabase).toBe(false);
expect(profile.database.type).toBeNull();
});
});

// ─── _detectFrontend ──────────────────────────────────────────

describe('_detectFrontend', () => {
it('should detect react', async () => {
mockPackageJson({ react: '^18.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectFrontend(profile);
expect(profile.hasFrontend).toBe(true);
expect(profile.frontend.framework).toBe('react');
});

it('should detect vue', async () => {
mockPackageJson({ vue: '^3.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectFrontend(profile);
expect(profile.hasFrontend).toBe(true);
expect(profile.frontend.framework).toBe('vue');
});

it('should detect angular', async () => {
mockPackageJson({ '@angular/core': '^17.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectFrontend(profile);
expect(profile.hasFrontend).toBe(true);
expect(profile.frontend.framework).toBe('angular');
});

it('should detect svelte', async () => {
mockPackageJson({ svelte: '^4.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectFrontend(profile);
expect(profile.hasFrontend).toBe(true);
expect(profile.frontend.framework).toBe('svelte');
});

it('should detect next.js as react', async () => {
mockPackageJson({ next: '^14.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectFrontend(profile);
expect(profile.hasFrontend).toBe(true);
expect(profile.frontend.framework).toBe('react');
});

it('should detect nuxt as vue', async () => {
mockPackageJson({ nuxt: '^3.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectFrontend(profile);
expect(profile.hasFrontend).toBe(true);
expect(profile.frontend.framework).toBe('vue');
});

it('should detect vite build tool', async () => {
mockPackageJson({ vite: '^5.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectFrontend(profile);
expect(profile.frontend.buildTool).toBe('vite');
});

it('should detect webpack build tool', async () => {
mockPackageJson({ webpack: '^5.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectFrontend(profile);
expect(profile.frontend.buildTool).toBe('webpack');
});

it('should detect tailwind styling', async () => {
mockPackageJson({ tailwindcss: '^3.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectFrontend(profile);
expect(profile.frontend.styling).toBe('tailwind');
});

it('should detect styled-components', async () => {
mockPackageJson({ 'styled-components': '^6.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectFrontend(profile);
expect(profile.frontend.styling).toBe('styled-components');
});

it('should detect emotion', async () => {
mockPackageJson({ '@emotion/react': '^11.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectFrontend(profile);
expect(profile.frontend.styling).toBe('emotion');
});

it('should detect scss/sass', async () => {
mockPackageJson({ sass: '^1.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectFrontend(profile);
expect(profile.frontend.styling).toBe('scss');
});

it('should detect shadcn from components/ui directory', async () => {
mockPaths({ 'package.json': true, 'src/components/ui': true });
fs.readJson.mockResolvedValue({ dependencies: { react: '^18' }, devDependencies: {} });

const profile = detector._createEmptyProfile();
await detector._detectFrontend(profile);
expect(profile.frontend.componentLibrary).toBe('shadcn');
});

it('should detect MUI component library', async () => {
mockPackageJson({ '@mui/material': '^5.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectFrontend(profile);
expect(profile.frontend.componentLibrary).toBe('mui');
});

it('should detect frontend from .tsx files in src', async () => {
mockPaths({ 'package.json': false, 'src': true });
fs.readdir.mockResolvedValue(['App.tsx', 'index.ts']);

const profile = detector._createEmptyProfile();
await detector._detectFrontend(profile);
expect(profile.hasFrontend).toBe(true);
});
});

// ─── _detectBackend ───────────────────────────────────────────

describe('_detectBackend', () => {
it('should detect express', async () => {
mockPackageJson({ express: '^4.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectBackend(profile);
expect(profile.hasBackend).toBe(true);
expect(profile.backend.type).toBe('express');
});

it('should detect fastify', async () => {
mockPackageJson({ fastify: '^4.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectBackend(profile);
expect(profile.hasBackend).toBe(true);
expect(profile.backend.type).toBe('fastify');
});

it('should detect nestjs', async () => {
mockPackageJson({ '@nestjs/core': '^10.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectBackend(profile);
expect(profile.hasBackend).toBe(true);
expect(profile.backend.type).toBe('nest');
});

it('should detect hono', async () => {
mockPackageJson({ hono: '^3.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectBackend(profile);
expect(profile.hasBackend).toBe(true);
expect(profile.backend.type).toBe('hono');
});

it('should detect edge functions from supabase/functions directory', async () => {
mockPaths({ 'package.json': false, 'supabase/functions': true });

const profile = detector._createEmptyProfile();
await detector._detectBackend(profile);
expect(profile.hasBackend).toBe(true);
expect(profile.backend.type).toBe('edge-functions');
});

it('should detect API routes from api directory', async () => {
mockPaths({ 'package.json': false, 'api': true });

const profile = detector._createEmptyProfile();
await detector._detectBackend(profile);
expect(profile.backend.hasAPI).toBe(true);
});

it('should detect API routes from pages/api (Next.js)', async () => {
mockPaths({ 'package.json': false, 'pages/api': true });

const profile = detector._createEmptyProfile();
await detector._detectBackend(profile);
expect(profile.backend.hasAPI).toBe(true);
});
});

// ─── _detectTypeScript ────────────────────────────────────────

describe('_detectTypeScript', () => {
it('should detect from typescript dependency', async () => {
mockPackageJson({}, { typescript: '^5.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectTypeScript(profile);
expect(profile.hasTypeScript).toBe(true);
});

it('should detect from tsconfig.json', async () => {
mockPaths({ 'package.json': false, 'tsconfig.json': true });

const profile = detector._createEmptyProfile();
await detector._detectTypeScript(profile);
expect(profile.hasTypeScript).toBe(true);
});

it('should not detect when no indicators', async () => {
const profile = detector._createEmptyProfile();
await detector._detectTypeScript(profile);
expect(profile.hasTypeScript).toBe(false);
});
});

// ─── _detectTests ─────────────────────────────────────────────

describe('_detectTests', () => {
it('should detect jest', async () => {
mockPackageJson({}, { jest: '^30.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectTests(profile);
expect(profile.hasTests).toBe(true);
});

it('should detect vitest', async () => {
mockPackageJson({}, { vitest: '^1.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectTests(profile);
expect(profile.hasTests).toBe(true);
});

it('should detect from tests directory', async () => {
mockPaths({ 'package.json': false, 'tests': true });

const profile = detector._createEmptyProfile();
await detector._detectTests(profile);
expect(profile.hasTests).toBe(true);
});

it('should detect from __tests__ directory', async () => {
mockPaths({ 'package.json': false, '__tests__': true });

const profile = detector._createEmptyProfile();
await detector._detectTests(profile);
expect(profile.hasTests).toBe(true);
});
});

// ─── _computeApplicablePhases ─────────────────────────────────

describe('_computeApplicablePhases', () => {
it('should always include phase 1', () => {
const profile = detector._createEmptyProfile();
detector._computeApplicablePhases(profile);
expect(profile.applicablePhases).toContain(1);
});

it('should include phase 2 when hasDatabase', () => {
const profile = detector._createEmptyProfile();
profile.hasDatabase = true;
detector._computeApplicablePhases(profile);
expect(profile.applicablePhases).toContain(2);
});

it('should not include phase 2 without database', () => {
const profile = detector._createEmptyProfile();
detector._computeApplicablePhases(profile);
expect(profile.applicablePhases).not.toContain(2);
});

it('should include phase 3 when hasFrontend', () => {
const profile = detector._createEmptyProfile();
profile.hasFrontend = true;
detector._computeApplicablePhases(profile);
expect(profile.applicablePhases).toContain(3);
});

it('should not include phase 3 without frontend', () => {
const profile = detector._createEmptyProfile();
detector._computeApplicablePhases(profile);
expect(profile.applicablePhases).not.toContain(3);
});

it('should always include phases 4-10', () => {
const profile = detector._createEmptyProfile();
detector._computeApplicablePhases(profile);
for (let i = 4; i <= 10; i++) {
expect(profile.applicablePhases).toContain(i);
}
});
});

// ─── _calculateConfidence ─────────────────────────────────────

describe('_calculateConfidence', () => {
it('should have base confidence of 50', () => {
const profile = detector._createEmptyProfile();
detector._calculateConfidence(profile);
expect(profile.confidence).toBe(50);
});

it('should add 10 for database detection', () => {
const profile = detector._createEmptyProfile();
profile.hasDatabase = true;
detector._calculateConfidence(profile);
expect(profile.confidence).toBeGreaterThanOrEqual(60);
});

it('should add more for database with type and env', () => {
const profile = detector._createEmptyProfile();
profile.hasDatabase = true;
profile.database.type = 'supabase';
profile.database.envVarsConfigured = true;
detector._calculateConfidence(profile);
expect(profile.confidence).toBe(70); // 50 + 10 + 5 + 5
});

it('should add for frontend with framework, build, and styling', () => {
const profile = detector._createEmptyProfile();
profile.hasFrontend = true;
profile.frontend.framework = 'react';
profile.frontend.buildTool = 'vite';
profile.frontend.styling = 'tailwind';
detector._calculateConfidence(profile);
expect(profile.confidence).toBe(70); // 50 + 10 + 5 + 3 + 2
});

it('should add for backend', () => {
const profile = detector._createEmptyProfile();
profile.hasBackend = true;
profile.backend.type = 'express';
detector._calculateConfidence(profile);
expect(profile.confidence).toBe(58); // 50 + 5 + 3
});

it('should add 3 for TypeScript', () => {
const profile = detector._createEmptyProfile();
profile.hasTypeScript = true;
detector._calculateConfidence(profile);
expect(profile.confidence).toBe(53);
});

it('should add 2 for tests', () => {
const profile = detector._createEmptyProfile();
profile.hasTests = true;
detector._calculateConfidence(profile);
expect(profile.confidence).toBe(52);
});

it('should cap confidence at 100', () => {
const profile = detector._createEmptyProfile();
profile.hasDatabase = true;
profile.database.type = 'supabase';
profile.database.envVarsConfigured = true;
profile.hasFrontend = true;
profile.frontend.framework = 'react';
profile.frontend.buildTool = 'vite';
profile.frontend.styling = 'tailwind';
profile.hasBackend = true;
profile.backend.type = 'express';
profile.hasTypeScript = true;
profile.hasTests = true;
detector._calculateConfidence(profile);
expect(profile.confidence).toBeLessThanOrEqual(100);
});
});

// ─── getSummary (static) ──────────────────────────────────────

describe('getSummary', () => {
it('should return "No stack detected" when nothing found', () => {
const profile = detector._createEmptyProfile();
expect(TechStackDetector.getSummary(profile)).toBe('No stack detected');
});

it('should include frontend with framework', () => {
const profile = detector._createEmptyProfile();
profile.hasFrontend = true;
profile.frontend.framework = 'react';
const summary = TechStackDetector.getSummary(profile);
expect(summary).toContain('Frontend: react');
});

it('should include frontend with styling', () => {
const profile = detector._createEmptyProfile();
profile.hasFrontend = true;
profile.frontend.framework = 'react';
profile.frontend.styling = 'tailwind';
const summary = TechStackDetector.getSummary(profile);
expect(summary).toContain('react + tailwind');
});

it('should include database with type', () => {
const profile = detector._createEmptyProfile();
profile.hasDatabase = true;
profile.database.type = 'supabase';
const summary = TechStackDetector.getSummary(profile);
expect(summary).toContain('Database: supabase');
});

it('should include database with RLS', () => {
const profile = detector._createEmptyProfile();
profile.hasDatabase = true;
profile.database.type = 'supabase';
profile.database.hasRLS = true;
const summary = TechStackDetector.getSummary(profile);
expect(summary).toContain('(RLS)');
});

it('should include backend type', () => {
const profile = detector._createEmptyProfile();
profile.hasBackend = true;
profile.backend.type = 'express';
const summary = TechStackDetector.getSummary(profile);
expect(summary).toContain('Backend: express');
});

it('should include TypeScript', () => {
const profile = detector._createEmptyProfile();
profile.hasTypeScript = true;
const summary = TechStackDetector.getSummary(profile);
expect(summary).toContain('TypeScript');
});

it('should join multiple parts with pipe', () => {
const profile = detector._createEmptyProfile();
profile.hasFrontend = true;
profile.frontend.framework = 'react';
profile.hasDatabase = true;
profile.database.type = 'supabase';
const summary = TechStackDetector.getSummary(profile);
expect(summary).toContain(' | ');
});
});

// ─── _loadPackageJson caching ─────────────────────────────────

describe('_loadPackageJson', () => {
it('should cache package.json after first load', async () => {
mockPaths({ 'package.json': true });
fs.readJson.mockResolvedValue({ name: 'test' });

await detector._loadPackageJson();
await detector._loadPackageJson();

// readJson should only be called once due to caching
expect(fs.readJson).toHaveBeenCalledTimes(1);
});

it('should return null when package.json does not exist', async () => {
fs.pathExists.mockResolvedValue(false);
const result = await detector._loadPackageJson();
expect(result).toBeNull();
});

it('should return null on parse error', async () => {
mockPaths({ 'package.json': true });
fs.readJson.mockRejectedValue(new Error('parse error'));

const result = await detector._loadPackageJson();
expect(result).toBeNull();
});
});
});
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description claims 74 tests, but there are actually 73 'it' blocks in this test file. Update the PR description to reflect the accurate count of 73 tests.

Copilot uses AI. Check for mistakes.
Comment on lines +670 to +674
await detector._loadPackageJson();
await detector._loadPackageJson();

// readJson should only be called once due to caching
expect(fs.readJson).toHaveBeenCalledTimes(1);
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this test verifies that readJson is called only once, it doesn't verify that the cached value is actually returned on the second call. Consider adding an assertion like: const result1 = await detector._loadPackageJson(); const result2 = await detector._loadPackageJson(); expect(result1).toBe(result2); to ensure the cache is functioning correctly.

Suggested change
await detector._loadPackageJson();
await detector._loadPackageJson();
// readJson should only be called once due to caching
expect(fs.readJson).toHaveBeenCalledTimes(1);
const result1 = await detector._loadPackageJson();
const result2 = await detector._loadPackageJson();
// readJson should only be called once due to caching
expect(fs.readJson).toHaveBeenCalledTimes(1);
// Both calls should return the same cached object instance
expect(result1).toBe(result2);

Copilot uses AI. Check for mistakes.
Comment on lines +351 to +412
describe('_detectBackend', () => {
it('should detect express', async () => {
mockPackageJson({ express: '^4.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectBackend(profile);
expect(profile.hasBackend).toBe(true);
expect(profile.backend.type).toBe('express');
});

it('should detect fastify', async () => {
mockPackageJson({ fastify: '^4.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectBackend(profile);
expect(profile.hasBackend).toBe(true);
expect(profile.backend.type).toBe('fastify');
});

it('should detect nestjs', async () => {
mockPackageJson({ '@nestjs/core': '^10.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectBackend(profile);
expect(profile.hasBackend).toBe(true);
expect(profile.backend.type).toBe('nest');
});

it('should detect hono', async () => {
mockPackageJson({ hono: '^3.0.0' });

const profile = detector._createEmptyProfile();
await detector._detectBackend(profile);
expect(profile.hasBackend).toBe(true);
expect(profile.backend.type).toBe('hono');
});

it('should detect edge functions from supabase/functions directory', async () => {
mockPaths({ 'package.json': false, 'supabase/functions': true });

const profile = detector._createEmptyProfile();
await detector._detectBackend(profile);
expect(profile.hasBackend).toBe(true);
expect(profile.backend.type).toBe('edge-functions');
});

it('should detect API routes from api directory', async () => {
mockPaths({ 'package.json': false, 'api': true });

const profile = detector._createEmptyProfile();
await detector._detectBackend(profile);
expect(profile.backend.hasAPI).toBe(true);
});

it('should detect API routes from pages/api (Next.js)', async () => {
mockPaths({ 'package.json': false, 'pages/api': true });

const profile = detector._createEmptyProfile();
await detector._detectBackend(profile);
expect(profile.backend.hasAPI).toBe(true);
});
});
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The backend detection implementation allows the backend.type to be overwritten when multiple backend frameworks are present (e.g., express and fastify both set the type without checking if it's already set). This differs from database detection which preserves the first match. Consider adding a test case that verifies the behavior when multiple backend frameworks are detected to document the expected behavior.

Copilot uses AI. Check for mistakes.
coderabbitai[bot]
coderabbitai bot previously approved these changes Feb 18, 2026
Add 44 tests covering TechStackDetector class:
- Database detection: Supabase, Prisma, PostgreSQL, MongoDB, MySQL, SQLite
- Frontend detection: React, Vue, Angular, Svelte, Next.js, Nuxt
- Build tools: Vite, Webpack; Styling: Tailwind, styled-components, Emotion, SCSS
- Component libraries: shadcn, MUI; Fallback from .jsx files in src
- Backend detection: Express, Fastify, NestJS, Hono, edge functions, API routes
- TypeScript and test framework detection
- Applicable phase computation and confidence scoring
- Static getSummary method and package.json caching
@nikolasdehor
Copy link
Contributor Author

Consolidated into #426

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test: add unit test coverage for tech-stack-detector

2 participants