diff --git a/features/feature-spec-discovery.md b/features/feature-spec-discovery.md index 45966dd..8493c11 100644 --- a/features/feature-spec-discovery.md +++ b/features/feature-spec-discovery.md @@ -128,6 +128,23 @@ Add shared discovery infrastructure for Issues #125 and #126: centralized parser **When** `PythonRouteMiner` runs with framework `django` **Then** both `path()` and `re_path()` entries are emitted as API route items. +### Story 11: TypeScript/JavaScript API route mining +**Scenario:** As a discovery pipeline, I need to extract Express routes from TS/JS sources. +**Given** TypeScript/JavaScript files selected from `ctx.file_index` and framework signal `express` +**When** `TypeScriptRouteMiner` runs +**Then** calls like `router.get("/health", handler)` emit `DiscoveredItem(kind=API_ROUTE)` with `http_method="GET"` and `path="/health"`. + +**Scenario:** As a miner maintainer, I need Next.js App Router support. +**Given** `app/**/route.ts` or `app/**/route.js` files with named exports like `export async function DELETE(...)` +**When** `TypeScriptRouteMiner` runs with framework signal `nextjs` +**Then** each HTTP export emits a separate API route item with inferred path (e.g. `app/api/users/[id]/route.ts` -> `/api/users/{id}`) +**And** metadata sets `is_file_based_route=True`. + +**Scenario:** As a pipeline operator, I need resilient parse handling. +**Given** one malformed TS/JS route file and one valid route file +**When** `TypeScriptRouteMiner` executes +**Then** it reports `MinerErrorKind.PARSE_ERROR` for parse failures and still returns items from valid files. + ## Acceptance Criteria - Language abstraction returns `SupportedLanguage` members for `.py`, `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs` and `None` otherwise. - `LanguageRegistry().parse(path_to_py_file)` returns `(node, SupportedLanguage.PYTHON)` for valid Python input. @@ -169,3 +186,10 @@ Add shared discovery infrastructure for Issues #125 and #126: centralized parser - Route metadata validates against `ApiRouteMeta`, including `response_model` for FastAPI and list-form `http_method` for Flask `methods=[...]`. - Django `urlpatterns` entries using both `path()` and `re_path()` produce API route items. - Missing Flask `methods` defaults to `["GET"]`. +- `TypeScriptRouteMiner` reads candidate files from `ctx.file_index.files_by_language(...)` and `ctx.file_index.files_matching("route.ts", "route.js")` and does not walk the filesystem directly. +- `TypeScriptRouteMiner` uses precomputed frameworks from `ctx.frameworks[SupportedLanguage.TYPESCRIPT]` / `ctx.frameworks[SupportedLanguage.JAVASCRIPT]` without re-parsing `package.json`. +- Express calls like `router.get("/health", handler)` emit API route items with method `GET`, path `/health`, and `framework="express"`. +- `.ts` files emit `language=SupportedLanguage.TYPESCRIPT`; `.js` files emit `language=SupportedLanguage.JAVASCRIPT`. +- Route metadata validates against `ApiRouteMeta` for both Express and Next.js outputs. +- `app/api/users/[id]/route.ts` exports like `DELETE` map to `/api/users/{id}` with `is_file_based_route=True`. +- Next.js route files with multiple HTTP exports emit one API route item per export. diff --git a/src/specleft/discovery/miners/__init__.py b/src/specleft/discovery/miners/__init__.py index 07504d1..1f10f79 100644 --- a/src/specleft/discovery/miners/__init__.py +++ b/src/specleft/discovery/miners/__init__.py @@ -8,6 +8,7 @@ from specleft.discovery.miners.python.tests import PythonTestMiner from specleft.discovery.miners.shared.docstrings import DocstringMiner from specleft.discovery.miners.shared.readme import ReadmeOverviewMiner +from specleft.discovery.miners.typescript.routes import TypeScriptRouteMiner from specleft.discovery.miners.typescript.tests import TypeScriptTestMiner __all__ = [ @@ -15,6 +16,7 @@ "PythonRouteMiner", "PythonTestMiner", "ReadmeOverviewMiner", + "TypeScriptRouteMiner", "TypeScriptTestMiner", "default_miners", ] diff --git a/src/specleft/discovery/miners/defaults.py b/src/specleft/discovery/miners/defaults.py index 52be3d4..f8e83a0 100644 --- a/src/specleft/discovery/miners/defaults.py +++ b/src/specleft/discovery/miners/defaults.py @@ -11,6 +11,7 @@ from specleft.discovery.miners.python.tests import PythonTestMiner from specleft.discovery.miners.shared.docstrings import DocstringMiner from specleft.discovery.miners.shared.readme import ReadmeOverviewMiner +from specleft.discovery.miners.typescript.routes import TypeScriptRouteMiner from specleft.discovery.miners.typescript.tests import TypeScriptTestMiner if TYPE_CHECKING: @@ -24,5 +25,6 @@ def default_miners() -> list[BaseMiner]: PythonTestMiner(), PythonRouteMiner(), TypeScriptTestMiner(), + TypeScriptRouteMiner(), DocstringMiner(), ] diff --git a/src/specleft/discovery/miners/typescript/__init__.py b/src/specleft/discovery/miners/typescript/__init__.py index 7235086..82bbdc2 100644 --- a/src/specleft/discovery/miners/typescript/__init__.py +++ b/src/specleft/discovery/miners/typescript/__init__.py @@ -4,6 +4,7 @@ """TypeScript/JavaScript-specific discovery miners.""" from specleft.discovery.miners.typescript.jsdoc import extract_jsdoc_items +from specleft.discovery.miners.typescript.routes import TypeScriptRouteMiner from specleft.discovery.miners.typescript.tests import TypeScriptTestMiner -__all__ = ["TypeScriptTestMiner", "extract_jsdoc_items"] +__all__ = ["TypeScriptRouteMiner", "TypeScriptTestMiner", "extract_jsdoc_items"] diff --git a/src/specleft/discovery/miners/typescript/routes.py b/src/specleft/discovery/miners/typescript/routes.py new file mode 100644 index 0000000..39b9f1f --- /dev/null +++ b/src/specleft/discovery/miners/typescript/routes.py @@ -0,0 +1,342 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 SpecLeft Contributors + +"""TypeScript/JavaScript API-route miner for Express and Next.js.""" + +from __future__ import annotations + +import re +import time +import uuid +from pathlib import Path +from typing import Any + +from specleft.discovery.context import MinerContext +from specleft.discovery.miners.shared.common import ( + elapsed_ms, + line_number, + node_text, + walk_tree, +) +from specleft.discovery.models import ( + ApiRouteMeta, + DiscoveredItem, + ItemKind, + MinerErrorKind, + MinerResult, + SupportedLanguage, +) + +_SUPPORTED_FRAMEWORKS = frozenset({"express", "nextjs"}) +_EXPRESS_METHODS = frozenset({"get", "post", "put", "patch", "delete", "use"}) +_STRING_NODE_TYPES = frozenset({"string", "template_string"}) +_NEXT_ROUTE_FILES = frozenset({"route.ts", "route.js"}) +_NEXT_EXPORT_METHODS = frozenset( + {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"} +) +_NEXT_EXPORT_PATTERN = re.compile( + r"^\s*export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b", + re.MULTILINE, +) + + +class TypeScriptRouteMiner: + """Extract API routes from TS/JS source files.""" + + miner_id = uuid.UUID("b06e8c32-c793-41dc-91de-12635d6c4f69") + name = "typescript_api_routes" + languages = frozenset({SupportedLanguage.TYPESCRIPT, SupportedLanguage.JAVASCRIPT}) + + def mine(self, ctx: MinerContext) -> MinerResult: + started = time.perf_counter() + frameworks = _enabled_frameworks(ctx) + if not frameworks: + return MinerResult( + miner_id=self.miner_id, + miner_name=self.name, + items=[], + duration_ms=elapsed_ms(started), + ) + + items: list[DiscoveredItem] = [] + parse_failures: list[Path] = [] + + for rel_path in _candidate_files(ctx, frameworks): + abs_path = ctx.root / rel_path + parsed = ctx.registry.parse(abs_path) + if parsed is None: + parse_failures.append(rel_path) + continue + + root_node, parsed_language = parsed + language = _language_for_file(rel_path, parsed_language) + + try: + source_bytes = abs_path.read_bytes() + except OSError: + parse_failures.append(rel_path) + continue + + if "express" in frameworks: + items.extend( + _extract_express_items( + root_node=root_node, + source_bytes=source_bytes, + file_path=rel_path, + language=language, + ) + ) + + if "nextjs" in frameworks and _is_next_route_file(rel_path): + items.extend( + _extract_nextjs_items( + source_bytes=source_bytes, + file_path=rel_path, + language=language, + ) + ) + + error_kind: MinerErrorKind | None = None + error: str | None = None + if parse_failures: + error_kind = MinerErrorKind.PARSE_ERROR + files = ", ".join(path.as_posix() for path in parse_failures) + error = f"Failed to parse TypeScript/JavaScript route files: {files}" + + return MinerResult( + miner_id=self.miner_id, + miner_name=self.name, + items=items, + error=error, + error_kind=error_kind, + duration_ms=elapsed_ms(started), + ) + + +def _enabled_frameworks(ctx: MinerContext) -> tuple[str, ...]: + detected: list[str] = [] + for language in (SupportedLanguage.TYPESCRIPT, SupportedLanguage.JAVASCRIPT): + for framework in ctx.frameworks.get(language, []): + if framework not in _SUPPORTED_FRAMEWORKS or framework in detected: + continue + detected.append(framework) + return tuple(detected) + + +def _candidate_files(ctx: MinerContext, frameworks: tuple[str, ...]) -> list[Path]: + candidates: set[Path] = set() + if "express" in frameworks: + for language in (SupportedLanguage.TYPESCRIPT, SupportedLanguage.JAVASCRIPT): + candidates.update(ctx.file_index.files_by_language(language)) + if "nextjs" in frameworks: + for candidate in ctx.file_index.files_matching("route.ts", "route.js"): + if _is_next_route_file(candidate): + candidates.add(candidate) + return sorted(candidates, key=lambda value: value.as_posix()) + + +def _language_for_file( + file_path: Path, + fallback: SupportedLanguage, +) -> SupportedLanguage: + suffix = file_path.suffix.lower() + if suffix in {".ts", ".tsx"}: + return SupportedLanguage.TYPESCRIPT + if suffix in {".js", ".jsx", ".mjs"}: + return SupportedLanguage.JAVASCRIPT + return fallback + + +def _extract_express_items( + *, + root_node: Any, + source_bytes: bytes, + file_path: Path, + language: SupportedLanguage, +) -> list[DiscoveredItem]: + items: list[DiscoveredItem] = [] + + for node in walk_tree(root_node): + if getattr(node, "type", "") != "call_expression": + continue + function_node = node.child_by_field_name("function") + if ( + function_node is None + or getattr(function_node, "type", "") != "member_expression" + ): + continue + + target = _member_call_target(function_node, source_bytes) + if target is None: + continue + receiver, method = target + if receiver not in {"app", "router"} or method not in _EXPRESS_METHODS: + continue + + args = _call_arguments(node) + path = _first_string_arg(args, source_bytes) + if path is None: + continue + + http_method = method.upper() + metadata = ApiRouteMeta( + http_method=http_method, + path=path, + framework="express", + handler_name=_handler_name(args, source_bytes), + ) + items.append( + DiscoveredItem( + kind=ItemKind.API_ROUTE, + name=f"{http_method} {path}", + file_path=file_path, + line_number=line_number(node), + language=language, + raw_text=None, + metadata=metadata.model_dump(), + confidence=0.9, + ) + ) + + return items + + +def _member_call_target( + function_node: Any, source_bytes: bytes +) -> tuple[str, str] | None: + object_name = _clean_identifier(_field_value(function_node, "object", source_bytes)) + property_name = _clean_identifier( + _field_value(function_node, "property", source_bytes) + ) + if object_name and property_name: + return object_name, property_name.lower() + + text = node_text(function_node, source_bytes).strip() + if "." not in text: + return None + object_part, property_part = text.rsplit(".", maxsplit=1) + object_name = _clean_identifier(object_part) + property_name = _clean_identifier(property_part) + if not object_name or not property_name: + return None + return object_name, property_name.lower() + + +def _call_arguments(node: Any) -> list[Any]: + arguments_node = node.child_by_field_name("arguments") + if arguments_node is None: + return [] + return list(getattr(arguments_node, "named_children", ())) + + +def _first_string_arg(args: list[Any], source_bytes: bytes) -> str | None: + for arg in args: + if getattr(arg, "type", "") not in _STRING_NODE_TYPES: + continue + value = _clean_string(node_text(arg, source_bytes)) + if value: + return value + return None + + +def _handler_name(args: list[Any], source_bytes: bytes) -> str | None: + for arg in args[1:]: + arg_type = getattr(arg, "type", "") + if arg_type in {"identifier", "property_identifier"}: + value = _clean_identifier(node_text(arg, source_bytes)) + if value: + return value + return None + + +def _extract_nextjs_items( + *, + source_bytes: bytes, + file_path: Path, + language: SupportedLanguage, +) -> list[DiscoveredItem]: + route_path = _next_route_path(file_path) + if route_path is None: + return [] + + source_text = source_bytes.decode("utf-8", errors="ignore") + items: list[DiscoveredItem] = [] + for match in _NEXT_EXPORT_PATTERN.finditer(source_text): + method = match.group(1).upper() + if method not in _NEXT_EXPORT_METHODS: + continue + metadata = ApiRouteMeta( + http_method=method, + path=route_path, + framework="nextjs", + handler_name=method, + is_file_based_route=True, + ) + items.append( + DiscoveredItem( + kind=ItemKind.API_ROUTE, + name=f"{method} {route_path}", + file_path=file_path, + line_number=_line_from_offset(source_text, match.start()), + language=language, + raw_text=None, + metadata=metadata.model_dump(), + confidence=0.9, + ) + ) + + return items + + +def _line_from_offset(source_text: str, offset: int) -> int: + return source_text.count("\n", 0, offset) + 1 + + +def _is_next_route_file(file_path: Path) -> bool: + return file_path.name in _NEXT_ROUTE_FILES and "app" in file_path.parts[:-1] + + +def _next_route_path(file_path: Path) -> str | None: + if not _is_next_route_file(file_path): + return None + + parts = list(file_path.parts) + app_index = parts.index("app") + route_parts = parts[app_index + 1 : -1] + if not route_parts: + return "/" + + normalized = [_normalize_next_segment(segment) for segment in route_parts] + return "/" + "/".join(normalized) + + +def _normalize_next_segment(segment: str) -> str: + if segment.startswith("[") and segment.endswith("]") and len(segment) > 2: + inner = segment[1:-1] + if inner.startswith("..."): + inner = inner[3:] + if inner: + return f"{{{inner}}}" + return segment + + +def _field_value(node: Any, field: str, source_bytes: bytes) -> str: + field_node = node.child_by_field_name(field) + if field_node is None: + return "" + return node_text(field_node, source_bytes).strip() + + +def _clean_string(raw: str) -> str: + value = raw.strip() + for quote in ('"', "'", "`"): + if value.startswith(quote) and value.endswith(quote) and len(value) >= 2: + return value[1:-1].strip() + return value + + +def _clean_identifier(raw: str) -> str: + value = raw.strip() + if value.startswith("this."): + value = value.split(".", maxsplit=1)[1] + return value.strip(" ?") diff --git a/tests/discovery/miners/test_typescript_routes.py b/tests/discovery/miners/test_typescript_routes.py new file mode 100644 index 0000000..ea3d7b0 --- /dev/null +++ b/tests/discovery/miners/test_typescript_routes.py @@ -0,0 +1,341 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 SpecLeft Contributors + +"""Tests for TypeScript/JavaScript API-route discovery miner.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from specleft.discovery.config import DiscoveryConfig +from specleft.discovery.context import MinerContext +from specleft.discovery.file_index import FileIndex +from specleft.discovery.miners.typescript.routes import TypeScriptRouteMiner +from specleft.discovery.models import ( + ApiRouteMeta as _ApiRouteMeta, + MinerErrorKind, + SupportedLanguage, +) + + +@dataclass +class _FakeNode: + type: str + text_value: str = "" + children: list[_FakeNode] = field(default_factory=list) + named_children: list[_FakeNode] = field(default_factory=list) + fields: dict[str, _FakeNode] = field(default_factory=dict) + start_point: tuple[int, int] = (0, 0) + end_point: tuple[int, int] = (0, 0) + + @property + def text(self) -> bytes: + return self.text_value.encode("utf-8") + + def child_by_field_name(self, name: str) -> _FakeNode | None: + return self.fields.get(name) + + +class _RegistryStub: + def __init__( + self, mapping: dict[Path, tuple[Any, SupportedLanguage] | None] + ) -> None: + self._mapping = mapping + self.calls: list[Path] = [] + + def parse(self, file_path: Path) -> tuple[Any, SupportedLanguage] | None: + self.calls.append(file_path) + return self._mapping.get(file_path) + + +def _context( + root: Path, + registry: _RegistryStub, + *, + frameworks: dict[SupportedLanguage, list[str]] | None = None, +) -> MinerContext: + return MinerContext( + root=root, + registry=registry, # type: ignore[arg-type] + file_index=FileIndex(root), + frameworks=frameworks or {}, + config=DiscoveryConfig.default(), + ) + + +def _fixture_dir() -> Path: + return Path(__file__).resolve().parents[2] / "fixtures" / "discovery" + + +def _identifier(value: str, row: int) -> _FakeNode: + return _FakeNode( + type="identifier", + text_value=value, + start_point=(row, 0), + end_point=(row, len(value)), + ) + + +def _property_identifier(value: str, row: int) -> _FakeNode: + return _FakeNode( + type="property_identifier", + text_value=value, + start_point=(row, 0), + end_point=(row, len(value)), + ) + + +def _string(value: str, row: int) -> _FakeNode: + return _FakeNode( + type="string", + text_value=value, + start_point=(row, 0), + end_point=(row, len(value)), + ) + + +def _member_expression(object_name: str, property_name: str, row: int) -> _FakeNode: + object_node = _identifier(object_name, row) + property_node = _property_identifier(property_name, row) + return _FakeNode( + type="member_expression", + children=[object_node, property_node], + named_children=[object_node, property_node], + fields={"object": object_node, "property": property_node}, + start_point=(row, 0), + end_point=(row, len(object_name) + len(property_name) + 1), + ) + + +def _call_expression(callee: _FakeNode, args: list[_FakeNode], row: int) -> _FakeNode: + arguments_node = _FakeNode( + type="arguments", + children=list(args), + named_children=list(args), + start_point=(row, 0), + end_point=(row, 0), + ) + return _FakeNode( + type="call_expression", + children=[callee, arguments_node], + named_children=[callee, arguments_node], + fields={"function": callee, "arguments": arguments_node}, + start_point=(row, 0), + end_point=(row + 1, 0), + ) + + +def _expression_statement(expression: _FakeNode, row: int) -> _FakeNode: + return _FakeNode( + type="expression_statement", + children=[expression], + named_children=[expression], + start_point=(row, 0), + end_point=(row + 1, 0), + ) + + +def _route_call( + *, + target: str, + method: str, + path: str, + handler: str, + row: int, +) -> _FakeNode: + return _call_expression( + _member_expression(target, method, row), + [_string(f"'{path}'", row), _identifier(handler, row)], + row, + ) + + +def _module(statements: list[_FakeNode]) -> _FakeNode: + return _FakeNode( + type="program", + children=list(statements), + named_children=list(statements), + start_point=(0, 0), + end_point=(max((child.end_point[0] for child in statements), default=0) + 1, 0), + ) + + +def _express_tree(route_calls: list[_FakeNode]) -> _FakeNode: + statements = [ + _expression_statement(call, row=index + 1) + for index, call in enumerate(route_calls) + ] + return _module(statements) + + +def test_typescript_route_miner_extracts_express_routes_and_languages( + tmp_path: Path, +) -> None: + fixture = _fixture_dir() / "sample_api.ts" + ts_file = tmp_path / "src" / "routes.ts" + js_file = tmp_path / "src" / "routes.js" + ts_file.parent.mkdir(parents=True) + + ts_file.write_text(fixture.read_text(encoding="utf-8"), encoding="utf-8") + js_file.write_text("app.delete('/users/{id}', deleteUser)\n", encoding="utf-8") + + registry = _RegistryStub( + { + ts_file: ( + _express_tree( + [ + _route_call( + target="router", + method="get", + path="/health", + handler="healthHandler", + row=1, + ) + ] + ), + SupportedLanguage.TYPESCRIPT, + ), + js_file: ( + _express_tree( + [ + _route_call( + target="app", + method="delete", + path="/users/{id}", + handler="deleteUser", + row=1, + ) + ] + ), + SupportedLanguage.JAVASCRIPT, + ), + } + ) + + result = TypeScriptRouteMiner().mine( + _context( + tmp_path, + registry, + frameworks={SupportedLanguage.TYPESCRIPT: ["express"]}, + ) + ) + + assert result.error is None + assert result.error_kind is None + assert len(result.items) == 2 + assert all(isinstance(item.typed_meta(), _ApiRouteMeta) for item in result.items) + + by_name = {item.name: item for item in result.items} + assert set(by_name) == {"GET /health", "DELETE /users/{id}"} + assert by_name["GET /health"].metadata["framework"] == "express" + assert by_name["GET /health"].metadata["handler_name"] == "healthHandler" + assert by_name["GET /health"].language == SupportedLanguage.TYPESCRIPT + assert by_name["DELETE /users/{id}"].language == SupportedLanguage.JAVASCRIPT + + +def test_typescript_route_miner_extracts_nextjs_app_router_exports( + tmp_path: Path, +) -> None: + fixture = _fixture_dir() / "app" / "api" / "users" / "[id]" / "route.ts" + route_file = tmp_path / "app" / "api" / "users" / "[id]" / "route.ts" + route_file.parent.mkdir(parents=True) + route_file.write_text(fixture.read_text(encoding="utf-8"), encoding="utf-8") + + registry = _RegistryStub( + { + route_file: (_module([]), SupportedLanguage.TYPESCRIPT), + } + ) + + result = TypeScriptRouteMiner().mine( + _context( + tmp_path, + registry, + frameworks={SupportedLanguage.TYPESCRIPT: ["nextjs"]}, + ) + ) + + assert result.error is None + assert result.error_kind is None + assert registry.calls == [route_file] + assert len(result.items) == 2 + assert all(item.metadata["framework"] == "nextjs" for item in result.items) + assert all(item.metadata["is_file_based_route"] is True for item in result.items) + assert {item.name for item in result.items} == { + "DELETE /api/users/{id}", + "POST /api/users/{id}", + } + assert all(item.metadata["path"] == "/api/users/{id}" for item in result.items) + + +def test_typescript_route_miner_reports_parse_failures_and_keeps_valid_items( + tmp_path: Path, +) -> None: + good_file = tmp_path / "src" / "routes.ts" + bad_file = tmp_path / "src" / "broken.js" + good_file.parent.mkdir(parents=True) + + good_file.write_text("app.get('/ok', okHandler)\n", encoding="utf-8") + bad_file.write_text("app.get('/broken'\n", encoding="utf-8") + + registry = _RegistryStub( + { + good_file: ( + _express_tree( + [ + _route_call( + target="app", + method="get", + path="/ok", + handler="okHandler", + row=1, + ) + ] + ), + SupportedLanguage.TYPESCRIPT, + ), + bad_file: None, + } + ) + + result = TypeScriptRouteMiner().mine( + _context( + tmp_path, + registry, + frameworks={SupportedLanguage.TYPESCRIPT: ["express"]}, + ) + ) + + assert result.error_kind == MinerErrorKind.PARSE_ERROR + assert result.error is not None + assert "src/broken.js" in result.error + assert len(result.items) == 1 + assert result.items[0].name == "GET /ok" + + +def test_typescript_route_miner_skips_when_framework_is_not_supported( + tmp_path: Path, +) -> None: + ts_file = tmp_path / "src" / "routes.ts" + ts_file.parent.mkdir(parents=True) + ts_file.write_text("router.get('/health', healthHandler)\n", encoding="utf-8") + + registry = _RegistryStub( + { + ts_file: (_module([]), SupportedLanguage.TYPESCRIPT), + } + ) + result = TypeScriptRouteMiner().mine( + _context( + tmp_path, + registry, + frameworks={SupportedLanguage.TYPESCRIPT: ["jest"]}, + ) + ) + + assert result.error is None + assert result.error_kind is None + assert result.items == [] + assert registry.calls == [] diff --git a/tests/fixtures/discovery/app/api/users/[id]/route.ts b/tests/fixtures/discovery/app/api/users/[id]/route.ts new file mode 100644 index 0000000..bda3183 --- /dev/null +++ b/tests/fixtures/discovery/app/api/users/[id]/route.ts @@ -0,0 +1,9 @@ +// Sample Next.js App Router route fixture for discovery tests + +export async function DELETE(request: Request) { + return Response.json({ ok: true }); +} + +export function POST(request: Request) { + return Response.json({ ok: true }); +} diff --git a/tests/fixtures/discovery/sample_api.ts b/tests/fixtures/discovery/sample_api.ts index 583ed2b..500f9f9 100644 --- a/tests/fixtures/discovery/sample_api.ts +++ b/tests/fixtures/discovery/sample_api.ts @@ -1,9 +1,17 @@ -// Sample API module for discovery tests +// Sample Express API module for discovery tests -export function getUser(userId: number): {id: number} { - return { id: userId }; +import express from "express"; + +const app = express(); +const router = express.Router(); + +router.get("/health", healthHandler); +app.post("/users", createUserHandler); + +function healthHandler() { + return { ok: true }; } -export function createUser(payload: {name: string}): {name: string} { - return payload; +function createUserHandler() { + return { created: true }; }