mirror of
https://github.com/millionco/react-doctor.git
synced 2026-09-14 20:00:24 +08:00
565 lines
25 KiB
TypeScript
565 lines
25 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { computeDiagnosticDelta } from "@react-doctor/core";
|
|
import type { Diagnostic } from "@react-doctor/core";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
|
|
import { createDiagnosticEvidenceReader } from "../src/cli/utils/read-diagnostic-evidence.js";
|
|
|
|
const makeDiagnostic = (overrides: Partial<Diagnostic> = {}): Diagnostic => ({
|
|
filePath: "src/wrapper-before.tsx",
|
|
plugin: "react-doctor",
|
|
rule: "click-events-have-key-events",
|
|
severity: "error",
|
|
title: "Click handler missing keyboard handler",
|
|
message: "A click handler needs a keyboard handler.",
|
|
help: "Add a keyboard handler.",
|
|
line: 1,
|
|
column: 1,
|
|
endLine: 3,
|
|
category: "Accessibility",
|
|
matchByOccurrence: true,
|
|
...overrides,
|
|
});
|
|
|
|
describe("createDiagnosticEvidenceReader", () => {
|
|
let rootDirectory: string;
|
|
|
|
beforeEach(() => {
|
|
rootDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-evidence-"));
|
|
fs.mkdirSync(path.join(rootDirectory, "src"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(rootDirectory, { recursive: true, force: true });
|
|
});
|
|
|
|
it("matches a handler moved behind one unambiguous forwarded prop", () => {
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/wrapper-before.tsx"),
|
|
"function WrapperBefore() {\n return <span onClick={() => handleSendMessage(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-message-bubble.tsx"),
|
|
"export function ChatMessageBubble({ onSuggestion }) {\n return <span onClick={() => onSuggestion(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-page.tsx"),
|
|
'import { ChatMessageBubble } from "./chat-message-bubble";\nconst handleSuggestion = useCallback((text) => {\n handleSendMessage(text);\n}, [handleSendMessage]);\n<ChatMessageBubble onSuggestion={handleSuggestion} />;\n',
|
|
);
|
|
|
|
const delta = computeDiagnosticDelta({
|
|
headDiagnostics: [
|
|
makeDiagnostic({ filePath: "src/chat-message-bubble.tsx", line: 2, endLine: 4 }),
|
|
],
|
|
baseDiagnostics: [makeDiagnostic({ line: 2, endLine: 4 })],
|
|
readHeadLine: () => null,
|
|
readBaseLine: () => null,
|
|
readHeadEvidence: createDiagnosticEvidenceReader(rootDirectory, {
|
|
resolveForwardedHandlers: true,
|
|
}),
|
|
readBaseEvidence: createDiagnosticEvidenceReader(rootDirectory),
|
|
});
|
|
|
|
expect(delta.newDiagnostics).toHaveLength(0);
|
|
expect(delta.fixedCount).toBe(0);
|
|
expect(delta.crossFileMatchCount).toBe(1);
|
|
});
|
|
|
|
it("resolves a handler through a qualified React.useCallback call", () => {
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/wrapper-before.tsx"),
|
|
"function WrapperBefore() {\n return <span onClick={() => handleSendMessage(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-message-bubble.tsx"),
|
|
"export function ChatMessageBubble({ onSuggestion }) {\n return <span onClick={() => onSuggestion(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-page.tsx"),
|
|
'import { ChatMessageBubble } from "./chat-message-bubble";\nconst handleSuggestion = React.useCallback((text) => {\n handleSendMessage(text);\n}, [handleSendMessage]);\n<ChatMessageBubble onSuggestion={handleSuggestion} />;\n',
|
|
);
|
|
|
|
const delta = computeDiagnosticDelta({
|
|
headDiagnostics: [
|
|
makeDiagnostic({ filePath: "src/chat-message-bubble.tsx", line: 2, endLine: 4 }),
|
|
],
|
|
baseDiagnostics: [makeDiagnostic({ line: 2, endLine: 4 })],
|
|
readHeadLine: () => null,
|
|
readBaseLine: () => null,
|
|
readHeadEvidence: createDiagnosticEvidenceReader(rootDirectory, {
|
|
resolveForwardedHandlers: true,
|
|
}),
|
|
readBaseEvidence: createDiagnosticEvidenceReader(rootDirectory),
|
|
});
|
|
|
|
expect(delta.newDiagnostics).toHaveLength(0);
|
|
expect(delta.fixedCount).toBe(0);
|
|
expect(delta.crossFileMatchCount).toBe(1);
|
|
});
|
|
|
|
it("unwraps typed handler bindings at component callsites", () => {
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/wrapper-before.tsx"),
|
|
"function WrapperBefore() {\n return <span onClick={() => handleSendMessage(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-message-bubble.tsx"),
|
|
"export function ChatMessageBubble({ onSuggestion }) {\n return <span onClick={() => onSuggestion(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-page.tsx"),
|
|
'import { ChatMessageBubble } from "./chat-message-bubble";\ntype Handler = (text: string) => void;\n<ChatMessageBubble onSuggestion={handleSendMessage as Handler} />;\n',
|
|
);
|
|
|
|
const delta = computeDiagnosticDelta({
|
|
headDiagnostics: [
|
|
makeDiagnostic({ filePath: "src/chat-message-bubble.tsx", line: 2, endLine: 4 }),
|
|
],
|
|
baseDiagnostics: [makeDiagnostic({ line: 2, endLine: 4 })],
|
|
readHeadLine: () => null,
|
|
readBaseLine: () => null,
|
|
readHeadEvidence: createDiagnosticEvidenceReader(rootDirectory, {
|
|
resolveForwardedHandlers: true,
|
|
}),
|
|
readBaseEvidence: createDiagnosticEvidenceReader(rootDirectory),
|
|
});
|
|
|
|
expect(delta.newDiagnostics).toHaveLength(0);
|
|
expect(delta.fixedCount).toBe(0);
|
|
expect(delta.crossFileMatchCount).toBe(1);
|
|
});
|
|
|
|
it("ignores recursive renders inside the diagnosed component", () => {
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/wrapper-before.tsx"),
|
|
"function WrapperBefore() {\n return <span onClick={() => handleSendMessage(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-message-bubble.tsx"),
|
|
"export function ChatMessageBubble({ onSuggestion, nested }) {\n const nestedBubble = nested ? <ChatMessageBubble onSuggestion={onSuggestion} nested={false} /> : null;\n return <span onClick={() => onSuggestion(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-page.tsx"),
|
|
'import { ChatMessageBubble } from "./chat-message-bubble";\n<ChatMessageBubble onSuggestion={handleSendMessage} nested />;\n',
|
|
);
|
|
|
|
const delta = computeDiagnosticDelta({
|
|
headDiagnostics: [
|
|
makeDiagnostic({ filePath: "src/chat-message-bubble.tsx", line: 3, endLine: 5 }),
|
|
],
|
|
baseDiagnostics: [makeDiagnostic({ line: 2, endLine: 4 })],
|
|
readHeadLine: () => null,
|
|
readBaseLine: () => null,
|
|
readHeadEvidence: createDiagnosticEvidenceReader(rootDirectory, {
|
|
resolveForwardedHandlers: true,
|
|
}),
|
|
readBaseEvidence: createDiagnosticEvidenceReader(rootDirectory),
|
|
});
|
|
|
|
expect(delta.newDiagnostics).toHaveLength(0);
|
|
expect(delta.fixedCount).toBe(0);
|
|
expect(delta.crossFileMatchCount).toBe(1);
|
|
});
|
|
|
|
it("resolves a default-exported arrow component through an aliased import", () => {
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/wrapper-before.tsx"),
|
|
"function WrapperBefore() {\n return <span onClick={() => handleSendMessage(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-message-bubble.tsx"),
|
|
"const ChatMessageBubble = ({ onSuggestion }) => {\n return <span onClick={() => onSuggestion(question)}>\n {question}\n </span>\n};\nexport default ChatMessageBubble;\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-page.tsx"),
|
|
'import Bubble from "./chat-message-bubble";\n<Bubble onSuggestion={handleSendMessage} />;\n',
|
|
);
|
|
|
|
const delta = computeDiagnosticDelta({
|
|
headDiagnostics: [
|
|
makeDiagnostic({ filePath: "src/chat-message-bubble.tsx", line: 2, endLine: 4 }),
|
|
],
|
|
baseDiagnostics: [makeDiagnostic({ line: 2, endLine: 4 })],
|
|
readHeadLine: () => null,
|
|
readBaseLine: () => null,
|
|
readHeadEvidence: createDiagnosticEvidenceReader(rootDirectory, {
|
|
resolveForwardedHandlers: true,
|
|
}),
|
|
readBaseEvidence: createDiagnosticEvidenceReader(rootDirectory),
|
|
});
|
|
|
|
expect(delta.newDiagnostics).toHaveLength(0);
|
|
expect(delta.crossFileMatchCount).toBe(1);
|
|
});
|
|
|
|
it("matches a handler extracted into a memo and forwardRef wrapped component", () => {
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/wrapper-before.tsx"),
|
|
"function WrapperBefore() {\n return <span onClick={() => handleSendMessage(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-message-bubble.tsx"),
|
|
'import { forwardRef, memo } from "react";\nexport const ChatMessageBubble = memo(forwardRef(({ onSuggestion }, _ref) => {\n return <span onClick={() => onSuggestion(question)}>\n {question}\n </span>\n}));\n',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-page.tsx"),
|
|
'import { ChatMessageBubble } from "./chat-message-bubble";\n<ChatMessageBubble onSuggestion={handleSendMessage} />;\n',
|
|
);
|
|
|
|
const delta = computeDiagnosticDelta({
|
|
headDiagnostics: [
|
|
makeDiagnostic({ filePath: "src/chat-message-bubble.tsx", line: 3, endLine: 5 }),
|
|
],
|
|
baseDiagnostics: [makeDiagnostic({ line: 2, endLine: 4 })],
|
|
readHeadLine: () => null,
|
|
readBaseLine: () => null,
|
|
readHeadEvidence: createDiagnosticEvidenceReader(rootDirectory, {
|
|
resolveForwardedHandlers: true,
|
|
}),
|
|
readBaseEvidence: createDiagnosticEvidenceReader(rootDirectory),
|
|
});
|
|
|
|
expect(delta.newDiagnostics).toHaveLength(0);
|
|
expect(delta.fixedCount).toBe(0);
|
|
expect(delta.crossFileMatchCount).toBe(1);
|
|
});
|
|
|
|
it("uses the diagnostic column to find a single-line arrow component", () => {
|
|
const componentSource =
|
|
"export const ChatMessageBubble = ({ onSuggestion }) => <span onClick={() => onSuggestion(question)}>{question}</span>;\n";
|
|
fs.writeFileSync(path.join(rootDirectory, "src/chat-message-bubble.tsx"), componentSource);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-page.tsx"),
|
|
'import { ChatMessageBubble } from "./chat-message-bubble";\n<ChatMessageBubble onSuggestion={handleSendMessage} />;\n',
|
|
);
|
|
const reader = createDiagnosticEvidenceReader(rootDirectory, {
|
|
resolveForwardedHandlers: true,
|
|
});
|
|
|
|
const evidence = reader(
|
|
makeDiagnostic({
|
|
filePath: "src/chat-message-bubble.tsx",
|
|
line: 1,
|
|
column: componentSource.indexOf("<span") + 1,
|
|
endLine: 1,
|
|
}),
|
|
);
|
|
|
|
expect(evidence).toContain("handleSendMessage(question)");
|
|
});
|
|
|
|
it("resolves a JavaScript import specifier to its TypeScript source", () => {
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/wrapper-before.tsx"),
|
|
"function WrapperBefore() {\n return <span onClick={() => handleSendMessage(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-message-bubble.tsx"),
|
|
"export function ChatMessageBubble({ onSuggestion }) {\n return <span onClick={() => onSuggestion(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-page.tsx"),
|
|
'import { ChatMessageBubble } from "./chat-message-bubble.js";\n<ChatMessageBubble onSuggestion={handleSendMessage} />;\n',
|
|
);
|
|
|
|
const delta = computeDiagnosticDelta({
|
|
headDiagnostics: [
|
|
makeDiagnostic({ filePath: "src/chat-message-bubble.tsx", line: 2, endLine: 4 }),
|
|
],
|
|
baseDiagnostics: [makeDiagnostic({ line: 2, endLine: 4 })],
|
|
readHeadLine: () => null,
|
|
readBaseLine: () => null,
|
|
readHeadEvidence: createDiagnosticEvidenceReader(rootDirectory, {
|
|
resolveForwardedHandlers: true,
|
|
}),
|
|
readBaseEvidence: createDiagnosticEvidenceReader(rootDirectory),
|
|
});
|
|
|
|
expect(delta.newDiagnostics).toHaveLength(0);
|
|
expect(delta.crossFileMatchCount).toBe(1);
|
|
});
|
|
|
|
it("does not treat a default import as a named diagnosed component", () => {
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/wrapper-before.tsx"),
|
|
"function WrapperBefore() {\n return <span onClick={() => handleSendMessage(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-message-bubble.tsx"),
|
|
"export default function OtherBubble() { return null; }\nexport function ChatMessageBubble({ onSuggestion }) {\n return <span onClick={() => onSuggestion(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-page.tsx"),
|
|
'import Bubble from "./chat-message-bubble";\n<Bubble onSuggestion={handleSendMessage} />;\n',
|
|
);
|
|
|
|
const delta = computeDiagnosticDelta({
|
|
headDiagnostics: [
|
|
makeDiagnostic({ filePath: "src/chat-message-bubble.tsx", line: 3, endLine: 5 }),
|
|
],
|
|
baseDiagnostics: [makeDiagnostic({ line: 2, endLine: 4 })],
|
|
readHeadLine: () => null,
|
|
readBaseLine: () => null,
|
|
readHeadEvidence: createDiagnosticEvidenceReader(rootDirectory, {
|
|
resolveForwardedHandlers: true,
|
|
}),
|
|
readBaseEvidence: createDiagnosticEvidenceReader(rootDirectory),
|
|
});
|
|
|
|
expect(delta.newDiagnostics).toHaveLength(1);
|
|
});
|
|
|
|
it("ignores same-named bindings in sibling lexical scopes", () => {
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/wrapper-before.tsx"),
|
|
"function WrapperBefore() {\n return <span onClick={() => handleSendMessage(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-message-bubble.tsx"),
|
|
"export function ChatMessageBubble({ onSuggestion }) {\n return <span onClick={() => onSuggestion(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-page.tsx"),
|
|
'import { ChatMessageBubble } from "./chat-message-bubble";\nconst handleSuggestion = (text) => handleSendMessage(text);\nfunction Sibling() {\n const handleSuggestion = (text) => discardMessage(text);\n return null;\n}\n<ChatMessageBubble onSuggestion={handleSuggestion} />;\n',
|
|
);
|
|
|
|
const delta = computeDiagnosticDelta({
|
|
headDiagnostics: [
|
|
makeDiagnostic({ filePath: "src/chat-message-bubble.tsx", line: 2, endLine: 4 }),
|
|
],
|
|
baseDiagnostics: [makeDiagnostic({ line: 2, endLine: 4 })],
|
|
readHeadLine: () => null,
|
|
readBaseLine: () => null,
|
|
readHeadEvidence: createDiagnosticEvidenceReader(rootDirectory, {
|
|
resolveForwardedHandlers: true,
|
|
}),
|
|
readBaseEvidence: createDiagnosticEvidenceReader(rootDirectory),
|
|
});
|
|
|
|
expect(delta.newDiagnostics).toHaveLength(0);
|
|
expect(delta.crossFileMatchCount).toBe(1);
|
|
});
|
|
|
|
it("does not resolve through an outer binding shadowed by a parameter", () => {
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/wrapper-before.tsx"),
|
|
"function WrapperBefore() {\n return <span onClick={() => handleSendMessage(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-message-bubble.tsx"),
|
|
"export function ChatMessageBubble({ onSuggestion }) {\n return <span onClick={() => onSuggestion(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-page.tsx"),
|
|
'import { ChatMessageBubble } from "./chat-message-bubble";\nconst handleSuggestion = (text) => handleSendMessage(text);\nfunction ChatPage({ handleSuggestion }) {\n return <ChatMessageBubble onSuggestion={handleSuggestion} />;\n}\n',
|
|
);
|
|
|
|
const delta = computeDiagnosticDelta({
|
|
headDiagnostics: [
|
|
makeDiagnostic({ filePath: "src/chat-message-bubble.tsx", line: 2, endLine: 4 }),
|
|
],
|
|
baseDiagnostics: [makeDiagnostic({ line: 2, endLine: 4 })],
|
|
readHeadLine: () => null,
|
|
readBaseLine: () => null,
|
|
readHeadEvidence: createDiagnosticEvidenceReader(rootDirectory, {
|
|
resolveForwardedHandlers: true,
|
|
}),
|
|
readBaseEvidence: createDiagnosticEvidenceReader(rootDirectory),
|
|
});
|
|
|
|
expect(delta.newDiagnostics).toHaveLength(1);
|
|
expect(delta.fixedCount).toBe(1);
|
|
});
|
|
|
|
it("treats imported handlers as opaque lexical bindings", () => {
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-message-bubble.tsx"),
|
|
"export function ChatMessageBubble({ onSuggestion }) {\n return <span onClick={() => onSuggestion(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/handlers.ts"),
|
|
"export const handleSuggestion = (text) => handleSendMessage(text);\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-page.tsx"),
|
|
'import { ChatMessageBubble } from "./chat-message-bubble";\nimport { handleSuggestion } from "./handlers";\nfunction Sibling() {\n const handleSuggestion = (text) => discardMessage(text);\n return null;\n}\n<ChatMessageBubble onSuggestion={handleSuggestion} />;\n',
|
|
);
|
|
const reader = createDiagnosticEvidenceReader(rootDirectory, {
|
|
resolveForwardedHandlers: true,
|
|
});
|
|
|
|
const evidence = reader(
|
|
makeDiagnostic({ filePath: "src/chat-message-bubble.tsx", line: 2, endLine: 4 }),
|
|
);
|
|
|
|
expect(evidence).toContain("handleSuggestion(question)");
|
|
expect(evidence).not.toContain("discardMessage(question)");
|
|
});
|
|
|
|
it("refuses to equate a forwarded prop with ambiguous callsite bindings", () => {
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/wrapper-before.tsx"),
|
|
"function WrapperBefore() {\n return <span onClick={() => handleSendMessage(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-message-bubble.tsx"),
|
|
"export function ChatMessageBubble({ onSuggestion }) {\n return <span onClick={() => onSuggestion(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-page.tsx"),
|
|
'import { ChatMessageBubble } from "./chat-message-bubble";\n<ChatMessageBubble onSuggestion={handleSendMessage} />;\n<ChatMessageBubble onSuggestion={discardMessage} />;\n',
|
|
);
|
|
|
|
const delta = computeDiagnosticDelta({
|
|
headDiagnostics: [
|
|
makeDiagnostic({ filePath: "src/chat-message-bubble.tsx", line: 2, endLine: 4 }),
|
|
],
|
|
baseDiagnostics: [makeDiagnostic({ line: 2, endLine: 4 })],
|
|
readHeadLine: () => null,
|
|
readBaseLine: () => null,
|
|
readHeadEvidence: createDiagnosticEvidenceReader(rootDirectory, {
|
|
resolveForwardedHandlers: true,
|
|
}),
|
|
readBaseEvidence: createDiagnosticEvidenceReader(rootDirectory),
|
|
});
|
|
|
|
expect(delta.newDiagnostics).toHaveLength(1);
|
|
expect(delta.fixedCount).toBe(1);
|
|
expect(delta.crossFileMatchCount).toBe(0);
|
|
});
|
|
|
|
it("refuses to equate a forwarded prop when a callsite binding is unresolved", () => {
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/wrapper-before.tsx"),
|
|
"function WrapperBefore() {\n return <span onClick={() => handleSendMessage(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-message-bubble.tsx"),
|
|
"export function ChatMessageBubble({ onSuggestion }) {\n return <span onClick={() => onSuggestion(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-page.tsx"),
|
|
'import { ChatMessageBubble } from "./chat-message-bubble";\n<ChatMessageBubble onSuggestion={handleSendMessage} />;\n<ChatMessageBubble onSuggestion={() => discardMessage()} />;\n',
|
|
);
|
|
|
|
const delta = computeDiagnosticDelta({
|
|
headDiagnostics: [
|
|
makeDiagnostic({ filePath: "src/chat-message-bubble.tsx", line: 2, endLine: 4 }),
|
|
],
|
|
baseDiagnostics: [makeDiagnostic({ line: 2, endLine: 4 })],
|
|
readHeadLine: () => null,
|
|
readBaseLine: () => null,
|
|
readHeadEvidence: createDiagnosticEvidenceReader(rootDirectory, {
|
|
resolveForwardedHandlers: true,
|
|
}),
|
|
readBaseEvidence: createDiagnosticEvidenceReader(rootDirectory),
|
|
});
|
|
|
|
expect(delta.newDiagnostics).toHaveLength(1);
|
|
expect(delta.fixedCount).toBe(1);
|
|
});
|
|
|
|
it("ignores same-named components that do not import the diagnosed component", () => {
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/wrapper-before.tsx"),
|
|
"function WrapperBefore() {\n return <span onClick={() => handleSendMessage(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-message-bubble.tsx"),
|
|
"export function ChatMessageBubble({ onSuggestion }) {\n return <span onClick={() => onSuggestion(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-page.tsx"),
|
|
'import { ChatMessageBubble } from "./chat-message-bubble";\n<ChatMessageBubble onSuggestion={handleSendMessage} />;\n',
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/unrelated.tsx"),
|
|
"const ChatMessageBubble = ({ onSuggestion }) => null;\n<ChatMessageBubble onSuggestion={discardMessage} />;\n",
|
|
);
|
|
|
|
const delta = computeDiagnosticDelta({
|
|
headDiagnostics: [
|
|
makeDiagnostic({ filePath: "src/chat-message-bubble.tsx", line: 2, endLine: 4 }),
|
|
],
|
|
baseDiagnostics: [makeDiagnostic({ line: 2, endLine: 4 })],
|
|
readHeadLine: () => null,
|
|
readBaseLine: () => null,
|
|
readHeadEvidence: createDiagnosticEvidenceReader(rootDirectory, {
|
|
resolveForwardedHandlers: true,
|
|
}),
|
|
readBaseEvidence: createDiagnosticEvidenceReader(rootDirectory),
|
|
});
|
|
|
|
expect(delta.newDiagnostics).toHaveLength(0);
|
|
expect(delta.crossFileMatchCount).toBe(1);
|
|
});
|
|
|
|
it("rejects a wrapper that changes forwarded arguments", () => {
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/wrapper-before.tsx"),
|
|
"function WrapperBefore() {\n return <span onClick={() => handleSendMessage(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-message-bubble.tsx"),
|
|
"export function ChatMessageBubble({ onSuggestion }) {\n return <span onClick={() => onSuggestion(question)}>\n {question}\n </span>\n}\n",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(rootDirectory, "src/chat-page.tsx"),
|
|
'import { ChatMessageBubble } from "./chat-message-bubble";\nconst handleSuggestion = (text) => handleSendMessage(text.trim());\n<ChatMessageBubble onSuggestion={handleSuggestion} />;\n',
|
|
);
|
|
|
|
const delta = computeDiagnosticDelta({
|
|
headDiagnostics: [
|
|
makeDiagnostic({ filePath: "src/chat-message-bubble.tsx", line: 2, endLine: 4 }),
|
|
],
|
|
baseDiagnostics: [makeDiagnostic({ line: 2, endLine: 4 })],
|
|
readHeadLine: () => null,
|
|
readBaseLine: () => null,
|
|
readHeadEvidence: createDiagnosticEvidenceReader(rootDirectory, {
|
|
resolveForwardedHandlers: true,
|
|
}),
|
|
readBaseEvidence: createDiagnosticEvidenceReader(rootDirectory),
|
|
});
|
|
|
|
expect(delta.newDiagnostics).toHaveLength(1);
|
|
});
|
|
|
|
it("does not read diagnostic paths outside the project", () => {
|
|
const outsideDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-outside-"));
|
|
const outsidePath = path.join(outsideDirectory, "outside.tsx");
|
|
fs.writeFileSync(outsidePath, "const secret = 'not evidence';\n");
|
|
const reader = createDiagnosticEvidenceReader(rootDirectory);
|
|
|
|
expect(reader(makeDiagnostic({ filePath: outsidePath }))).toBeNull();
|
|
|
|
fs.rmSync(outsideDirectory, { recursive: true, force: true });
|
|
});
|
|
|
|
it("does not follow diagnostic symlinks", () => {
|
|
const outsideDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-outside-"));
|
|
const outsidePath = path.join(outsideDirectory, "outside.tsx");
|
|
fs.writeFileSync(outsidePath, "const secret = 'not evidence';\n");
|
|
fs.symlinkSync(outsidePath, path.join(rootDirectory, "src/linked.tsx"));
|
|
const reader = createDiagnosticEvidenceReader(rootDirectory);
|
|
|
|
expect(reader(makeDiagnostic({ filePath: "src/linked.tsx" }))).toBeNull();
|
|
|
|
fs.rmSync(outsideDirectory, { recursive: true, force: true });
|
|
});
|
|
|
|
it("does not follow ancestor symlinks outside the project", () => {
|
|
const outsideDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-outside-"));
|
|
fs.writeFileSync(
|
|
path.join(outsideDirectory, "outside.tsx"),
|
|
"const secret = 'not evidence';\n",
|
|
);
|
|
fs.symlinkSync(outsideDirectory, path.join(rootDirectory, "src/linked-directory"));
|
|
const reader = createDiagnosticEvidenceReader(rootDirectory);
|
|
|
|
expect(reader(makeDiagnostic({ filePath: "src/linked-directory/outside.tsx" }))).toBeNull();
|
|
|
|
fs.rmSync(outsideDirectory, { recursive: true, force: true });
|
|
});
|
|
});
|