Files
software-mansion__argent/packages/tool-server/test/react-profiler/component-source-duplicates.test.ts
Ignacy Łątka abcbce991e fix(profiler): keep all same-named components and surface platform variants (#368)
## Problem

The AST component index (`buildAstIndexWithDiagnostics`) is keyed by
component **name**. When two files define a component with the same name
— most commonly platform variants, e.g. `List` in `List.tsx` and `List`
in `List.web.tsx` — the index kept whichever file the directory walk
happened to reach **first** (`if (!index.has(name))`) and threw the rest
away.

So a lookup for `List` could return the wrong platform variant's source,
with no signal that other matches even existed. And because it depended
on filesystem walk order, *which* `List` survived was effectively
arbitrary.

Raised in review on #287.

## Fix

- **Collect every match per name** instead of keeping the first walked.
- **Choose a deterministic primary**: base files are preferred over
`.web` / `.ios` / `.android` / `.native` variants (those only override
the base when bundling for that platform), then ties break on path →
line → column. The result no longer depends on directory-walk order.
- **Surface the rest** as `otherMatches` on the index entry.
`react-profiler-component-source` now returns `otherMatches: [{ file,
line, col }]` alongside the primary, so a caller asking for `List` can
tell the locations apart instead of trusting a single arbitrary file.

`react-profiler-analyze` needs no change — it enriches findings with the
(now deterministic) primary location, which is strictly better than
before; surfacing per-finding alternates there is out of scope.

## Tests

`packages/tool-server/test/react-profiler/ast-index.test.ts` (index
layer) and a new `component-source-duplicates.test.ts` (end-to-end
through the tool's `execute`):

- same name in `List.tsx` + `List.web.tsx` → primary is the base file,
its source is returned, `List.web.tsx` is under `otherMatches`;
- only platform variants (`.android` + `.ios`, no base) → deterministic
primary by path;
- uniquely-named component → no `otherMatches`.

The two collision assertions fail on pre-fix code (no `otherMatches`,
arbitrary primary) and pass with the fix. Verified locally with Node 20:
`vitest run test/react-profiler/` — 80 passed; `tsc --build` clean;
prettier + eslint clean.
2026-06-25 21:48:16 +02:00

49 lines
2.2 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { reactProfilerComponentSourceTool } from "../../src/tools/profiler/react/react-profiler-component-source";
/**
* End-to-end through the tool's execute(): when a component name resolves to
* several files (platform variants), the lookup must return one deterministic
* primary's source AND surface the rest under otherMatches — otherwise a caller
* asking for "List" is silently handed whichever file the directory walk
* happened to reach first, which may be the wrong platform variant.
*/
describe("react-profiler-component-source: duplicate component names", () => {
it("returns the base-file primary source and surfaces variants under otherMatches", async () => {
const dir = mkdtempSync(join(tmpdir(), "component-source-dup-"));
mkdirSync(join(dir, "components"), { recursive: true });
const base = join(dir, "components", "List.tsx");
const web = join(dir, "components", "List.web.tsx");
writeFileSync(base, `export function List() { return <View>NATIVE_MARKER</View>; }\n`);
writeFileSync(web, `export function List() { return <div>WEB_MARKER</div>; }\n`);
const result = (await reactProfilerComponentSourceTool.execute(
{},
{ component_name: "List", project_root: dir }
)) as Record<string, unknown>;
expect(result.found).toBe(true);
expect(result.file).toBe(base);
// Source must come from the primary (base) file, not the web variant.
expect(result.source).toContain("NATIVE_MARKER");
expect(result.source).not.toContain("WEB_MARKER");
expect(result.otherMatches).toEqual([{ file: web, line: 1, col: 16 }]);
});
it("omits otherMatches for a uniquely-named component", async () => {
const dir = mkdtempSync(join(tmpdir(), "component-source-uniq-"));
writeFileSync(join(dir, "Solo.tsx"), `export function Solo() { return <View />; }\n`);
const result = (await reactProfilerComponentSourceTool.execute(
{},
{ component_name: "Solo", project_root: dir }
)) as Record<string, unknown>;
expect(result.found).toBe(true);
expect(result.otherMatches).toBeUndefined();
});
});