chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import jscodeshift from "jscodeshift";
|
||||
import transform from "../migrate-attachments";
|
||||
|
||||
const j = jscodeshift.withParser("tsx");
|
||||
|
||||
function run(source: string): string {
|
||||
const result = transform(
|
||||
{ source, path: "test.tsx" },
|
||||
{ jscodeshift: j, j, stats: () => {}, report: () => {} },
|
||||
);
|
||||
return result ?? source;
|
||||
}
|
||||
|
||||
describe("migrate-attachments codemod", () => {
|
||||
// -----------------------------------------------------------------------
|
||||
// Props transformation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("JSX props", () => {
|
||||
it("transforms imageUploadsEnabled to attachments", () => {
|
||||
const input = `
|
||||
import { CopilotChat } from "@copilotkit/react-ui";
|
||||
<CopilotChat imageUploadsEnabled={true} />;
|
||||
`;
|
||||
const output = run(input);
|
||||
expect(output).toContain("attachments={{");
|
||||
expect(output).toContain("enabled: true");
|
||||
expect(output).not.toContain("imageUploadsEnabled");
|
||||
});
|
||||
|
||||
it("transforms inputFileAccept to attachments.accept", () => {
|
||||
const input = `
|
||||
import { CopilotChat } from "@copilotkit/react-ui";
|
||||
<CopilotChat inputFileAccept="image/*" />;
|
||||
`;
|
||||
const output = run(input);
|
||||
expect(output).toContain('accept: "image/*"');
|
||||
expect(output).not.toContain("inputFileAccept");
|
||||
});
|
||||
|
||||
it("merges both props into a single attachments object", () => {
|
||||
const input = `
|
||||
import { CopilotChat } from "@copilotkit/react-ui";
|
||||
<CopilotChat imageUploadsEnabled={true} inputFileAccept="image/*,.pdf" />;
|
||||
`;
|
||||
const output = run(input);
|
||||
expect(output).toContain("enabled: true");
|
||||
expect(output).toContain('accept: "image/*,.pdf"');
|
||||
expect(output).not.toContain("imageUploadsEnabled");
|
||||
expect(output).not.toContain("inputFileAccept");
|
||||
});
|
||||
|
||||
it("works on CopilotSidebar", () => {
|
||||
const input = `
|
||||
import { CopilotSidebar } from "@copilotkit/react-ui";
|
||||
<CopilotSidebar imageUploadsEnabled={true} />;
|
||||
`;
|
||||
const output = run(input);
|
||||
expect(output).toContain("attachments={{");
|
||||
expect(output).not.toContain("imageUploadsEnabled");
|
||||
});
|
||||
|
||||
it("works on CopilotPopup", () => {
|
||||
const input = `
|
||||
import { CopilotPopup } from "@copilotkit/react-ui";
|
||||
<CopilotPopup imageUploadsEnabled={true} />;
|
||||
`;
|
||||
const output = run(input);
|
||||
expect(output).toContain("attachments={{");
|
||||
expect(output).not.toContain("imageUploadsEnabled");
|
||||
});
|
||||
|
||||
it("preserves other props", () => {
|
||||
const input = `
|
||||
import { CopilotChat } from "@copilotkit/react-ui";
|
||||
<CopilotChat className="my-chat" imageUploadsEnabled={true} labels={{ placeholder: "Ask..." }} />;
|
||||
`;
|
||||
const output = run(input);
|
||||
expect(output).toContain('className="my-chat"');
|
||||
expect(output).toContain("labels=");
|
||||
expect(output).toContain("attachments={{");
|
||||
});
|
||||
|
||||
it("preserves imageUploadsEnabled={false}", () => {
|
||||
const input = `
|
||||
import { CopilotChat } from "@copilotkit/react-ui";
|
||||
<CopilotChat imageUploadsEnabled={false} />;
|
||||
`;
|
||||
const output = run(input);
|
||||
expect(output).toContain("enabled: false");
|
||||
expect(output).not.toContain("enabled: true");
|
||||
});
|
||||
|
||||
it("preserves dynamic imageUploadsEnabled expression", () => {
|
||||
const input = `
|
||||
import { CopilotChat } from "@copilotkit/react-ui";
|
||||
<CopilotChat imageUploadsEnabled={isEnabled} />;
|
||||
`;
|
||||
const output = run(input);
|
||||
expect(output).toContain("enabled: isEnabled");
|
||||
});
|
||||
|
||||
it("handles shorthand imageUploadsEnabled (no value)", () => {
|
||||
const input = `
|
||||
import { CopilotChat } from "@copilotkit/react-ui";
|
||||
<CopilotChat imageUploadsEnabled />;
|
||||
`;
|
||||
const output = run(input);
|
||||
expect(output).toContain("enabled: true");
|
||||
});
|
||||
|
||||
it("skips if attachments prop already exists", () => {
|
||||
const input = `
|
||||
import { CopilotChat } from "@copilotkit/react-ui";
|
||||
<CopilotChat imageUploadsEnabled={true} attachments={{ enabled: true }} />;
|
||||
`;
|
||||
const output = run(input);
|
||||
// Should not double-add attachments — leaves as-is
|
||||
expect(output).toContain("imageUploadsEnabled");
|
||||
});
|
||||
|
||||
it("preserves dynamic inputFileAccept expression", () => {
|
||||
const input = `
|
||||
import { CopilotChat } from "@copilotkit/react-ui";
|
||||
<CopilotChat imageUploadsEnabled={true} inputFileAccept={acceptTypes} />;
|
||||
`;
|
||||
const output = run(input);
|
||||
expect(output).toContain("accept: acceptTypes");
|
||||
expect(output).toContain("enabled: true");
|
||||
expect(output).not.toContain("inputFileAccept");
|
||||
});
|
||||
|
||||
it("ignores non-CopilotKit components", () => {
|
||||
const input = `
|
||||
<SomeOtherChat imageUploadsEnabled={true} />;
|
||||
`;
|
||||
const output = run(input);
|
||||
expect(output).toContain("imageUploadsEnabled");
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Import renames
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("import renames", () => {
|
||||
it("renames ImageUploadQueue to AttachmentQueue", () => {
|
||||
const input = `
|
||||
import { ImageUploadQueue } from "@copilotkit/react-ui";
|
||||
<ImageUploadQueue images={imgs} />;
|
||||
`;
|
||||
const output = run(input);
|
||||
expect(output).toContain("AttachmentQueue");
|
||||
expect(output).not.toContain("ImageUploadQueue");
|
||||
});
|
||||
|
||||
it("renames ImageUpload type to Attachment", () => {
|
||||
const input = `
|
||||
import type { ImageUpload } from "@copilotkit/react-ui";
|
||||
const x: ImageUpload = { contentType: "", bytes: "" };
|
||||
`;
|
||||
const output = run(input);
|
||||
expect(output).toContain("Attachment");
|
||||
expect(output).not.toContain("ImageUpload");
|
||||
});
|
||||
|
||||
it("handles aliased imports (keeps alias, renames imported)", () => {
|
||||
const input = `
|
||||
import { ImageUploadQueue as MyQueue } from "@copilotkit/react-ui";
|
||||
<MyQueue images={imgs} />;
|
||||
`;
|
||||
const output = run(input);
|
||||
// Imported name changes, but local alias stays
|
||||
expect(output).toContain("AttachmentQueue as MyQueue");
|
||||
expect(output).toContain("<MyQueue");
|
||||
});
|
||||
|
||||
it("ignores imports from other packages", () => {
|
||||
const input = `
|
||||
import { ImageUploadQueue } from "some-other-package";
|
||||
`;
|
||||
const output = run(input);
|
||||
expect(output).toContain("ImageUploadQueue");
|
||||
expect(output).not.toContain("AttachmentQueue");
|
||||
});
|
||||
|
||||
it("does not rename local variables that shadow the import name", () => {
|
||||
const input = `
|
||||
import type { ImageUpload } from "@copilotkit/react-ui";
|
||||
const x: ImageUpload = {} as any;
|
||||
const ImageUpload = "unrelated local variable";
|
||||
console.log(ImageUpload);
|
||||
`;
|
||||
const output = run(input);
|
||||
// Import and type reference should be renamed
|
||||
expect(output).toContain("import type { Attachment }");
|
||||
expect(output).toContain("const x: Attachment");
|
||||
// Local variable declaration and its reference should NOT be renamed
|
||||
expect(output).toContain(
|
||||
'const ImageUpload = "unrelated local variable"',
|
||||
);
|
||||
expect(output).toContain("console.log(ImageUpload)");
|
||||
});
|
||||
|
||||
it("does not rename object property keys or member expressions", () => {
|
||||
const input = `
|
||||
import type { ImageUpload } from "@copilotkit/react-ui";
|
||||
const x: ImageUpload = {} as any;
|
||||
const config = { ImageUpload: true };
|
||||
const val = obj.ImageUpload;
|
||||
`;
|
||||
const output = run(input);
|
||||
// Import and type reference should be renamed
|
||||
expect(output).toContain("import type { Attachment }");
|
||||
expect(output).toContain("const x: Attachment");
|
||||
// Object key and member access should NOT be renamed
|
||||
expect(output).toContain("{ ImageUpload: true }");
|
||||
expect(output).toContain("obj.ImageUpload");
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Idempotency and no-op
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe("idempotency", () => {
|
||||
it("running twice produces the same result", () => {
|
||||
const input = `
|
||||
import { CopilotChat, ImageUploadQueue } from "@copilotkit/react-ui";
|
||||
<CopilotChat imageUploadsEnabled={true} inputFileAccept="image/*" />;
|
||||
<ImageUploadQueue images={imgs} />;
|
||||
`;
|
||||
const first = run(input);
|
||||
const second = run(first);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no-op on unrelated code", () => {
|
||||
it("does not modify files without deprecated APIs", () => {
|
||||
const input = `
|
||||
import { useState } from "react";
|
||||
function App() {
|
||||
const [count, setCount] = useState(0);
|
||||
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
|
||||
}
|
||||
`;
|
||||
const output = run(input);
|
||||
expect(output).toBe(input);
|
||||
});
|
||||
|
||||
it("does not modify already-migrated code", () => {
|
||||
const input = `
|
||||
import { CopilotChat, AttachmentQueue } from "@copilotkit/react-ui";
|
||||
import type { Attachment } from "@copilotkit/react-ui";
|
||||
<CopilotChat attachments={{ enabled: true, accept: "image/*" }} />;
|
||||
`;
|
||||
const output = run(input);
|
||||
expect(output).toBe(input);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Codemod: migrate-attachments
|
||||
*
|
||||
* Migrates from the deprecated image-upload API to the new attachments API.
|
||||
*
|
||||
* Transformations:
|
||||
* 1. JSX props on CopilotChat / CopilotSidebar / CopilotPopup:
|
||||
* - imageUploadsEnabled={true} → attachments={{ enabled: true }}
|
||||
* - inputFileAccept="..." → merged into attachments={{ accept: "..." }}
|
||||
* - Both props present → attachments={{ enabled: true, accept: "..." }}
|
||||
*
|
||||
* 2. Named imports from "@copilotkit/react-ui":
|
||||
* - ImageUploadQueue → AttachmentQueue
|
||||
* - ImageUpload (type) → Attachment (type)
|
||||
*
|
||||
* Usage:
|
||||
* npx jscodeshift -t ./codemods/migrate-attachments.ts --extensions=tsx,ts ./src
|
||||
*/
|
||||
|
||||
import type {
|
||||
API,
|
||||
FileInfo,
|
||||
JSXElement,
|
||||
JSXAttribute,
|
||||
JSXExpressionContainer,
|
||||
ImportSpecifier,
|
||||
} from "jscodeshift";
|
||||
|
||||
const COPILOTKIT_PACKAGE = "@copilotkit/react-ui";
|
||||
const TARGET_COMPONENTS = new Set([
|
||||
"CopilotChat",
|
||||
"CopilotSidebar",
|
||||
"CopilotPopup",
|
||||
]);
|
||||
|
||||
const IMPORT_RENAMES: Record<string, string> = {
|
||||
ImageUploadQueue: "AttachmentQueue",
|
||||
ImageUpload: "Attachment",
|
||||
};
|
||||
|
||||
export default function transform(file: FileInfo, api: API) {
|
||||
const j = api.jscodeshift;
|
||||
const root = j(file.source);
|
||||
let changed = false;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 1. Rename imports from @copilotkit/react-ui
|
||||
// -----------------------------------------------------------------------
|
||||
root
|
||||
.find(j.ImportDeclaration, { source: { value: COPILOTKIT_PACKAGE } })
|
||||
.forEach((path) => {
|
||||
const specifiers = path.node.specifiers;
|
||||
if (!specifiers) return;
|
||||
|
||||
for (const spec of specifiers) {
|
||||
if (spec.type !== "ImportSpecifier") continue;
|
||||
const imported = (spec as ImportSpecifier).imported;
|
||||
if (imported.type !== "Identifier") continue;
|
||||
|
||||
const newName = IMPORT_RENAMES[imported.name];
|
||||
if (!newName) continue;
|
||||
|
||||
const localName = spec.local?.name ?? imported.name;
|
||||
const isAliased = localName !== imported.name;
|
||||
|
||||
// Rename the imported identifier
|
||||
imported.name = newName;
|
||||
|
||||
// If the local name matched the old imported name (not aliased),
|
||||
// update references in the file to use the new name.
|
||||
//
|
||||
// To avoid corrupting unrelated code, we check if any local
|
||||
// declaration (variable, function, class) shadows the imported
|
||||
// name. If so, we only rename type-position references (which
|
||||
// unambiguously refer to the type import) and leave value-position
|
||||
// references alone since they may refer to the local binding.
|
||||
if (!isAliased) {
|
||||
const hasShadow =
|
||||
root.find(j.VariableDeclarator, {
|
||||
id: { type: "Identifier", name: localName },
|
||||
}).length > 0 ||
|
||||
root.find(j.FunctionDeclaration, {
|
||||
id: { type: "Identifier", name: localName },
|
||||
}).length > 0 ||
|
||||
root.find(j.ClassDeclaration, {
|
||||
id: { type: "Identifier", name: localName },
|
||||
}).length > 0;
|
||||
|
||||
root.find(j.Identifier, { name: localName }).forEach((idPath) => {
|
||||
// Skip the import specifier itself — already renamed above
|
||||
if (idPath.parent.node === spec) return;
|
||||
|
||||
const parent = idPath.parent.node;
|
||||
|
||||
// Skip declaration positions — these define new bindings
|
||||
if (
|
||||
parent.type === "VariableDeclarator" &&
|
||||
parent.id === idPath.node
|
||||
)
|
||||
return;
|
||||
if (
|
||||
parent.type === "FunctionDeclaration" &&
|
||||
parent.id === idPath.node
|
||||
)
|
||||
return;
|
||||
if (parent.type === "ClassDeclaration" && parent.id === idPath.node)
|
||||
return;
|
||||
if (
|
||||
parent.type === "TSTypeAliasDeclaration" &&
|
||||
parent.id === idPath.node
|
||||
)
|
||||
return;
|
||||
if (
|
||||
parent.type === "TSInterfaceDeclaration" &&
|
||||
parent.id === idPath.node
|
||||
)
|
||||
return;
|
||||
|
||||
// Skip non-computed object property keys and member expression properties
|
||||
if (
|
||||
(parent.type === "Property" ||
|
||||
parent.type === "ObjectProperty") &&
|
||||
parent.key === idPath.node &&
|
||||
!parent.computed
|
||||
)
|
||||
return;
|
||||
if (
|
||||
parent.type === "MemberExpression" &&
|
||||
parent.property === idPath.node &&
|
||||
!parent.computed
|
||||
)
|
||||
return;
|
||||
|
||||
// Skip import specifiers from other packages
|
||||
if (
|
||||
parent.type === "ImportSpecifier" &&
|
||||
idPath.parent.parent?.node !== path.node
|
||||
)
|
||||
return;
|
||||
|
||||
// If a local declaration shadows this name, only rename
|
||||
// unambiguous type-position references (e.g. type annotations)
|
||||
if (hasShadow) {
|
||||
const isTypePosition =
|
||||
parent.type === "TSTypeReference" ||
|
||||
parent.type === "TSTypeAnnotation" ||
|
||||
parent.type === "TSTypeQuery";
|
||||
if (!isTypePosition) return;
|
||||
}
|
||||
|
||||
idPath.node.name = newName;
|
||||
});
|
||||
|
||||
// Only rename JSX identifiers if there's no shadow
|
||||
if (!hasShadow) {
|
||||
root
|
||||
.find(j.JSXIdentifier, { name: localName })
|
||||
.forEach((idPath) => {
|
||||
idPath.node.name = newName;
|
||||
});
|
||||
}
|
||||
|
||||
if (spec.local) {
|
||||
spec.local.name = newName;
|
||||
}
|
||||
}
|
||||
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2. Transform JSX props on CopilotChat / CopilotSidebar / CopilotPopup
|
||||
// -----------------------------------------------------------------------
|
||||
root.find(j.JSXOpeningElement).forEach((path) => {
|
||||
const nameNode = path.node.name;
|
||||
if (nameNode.type !== "JSXIdentifier") return;
|
||||
if (!TARGET_COMPONENTS.has(nameNode.name)) return;
|
||||
|
||||
const attrs = path.node.attributes;
|
||||
if (!attrs) return;
|
||||
|
||||
// Find the deprecated props
|
||||
let imageUploadsAttr: JSXAttribute | null = null;
|
||||
let inputFileAcceptAttr: JSXAttribute | null = null;
|
||||
let existingAttachmentsAttr: JSXAttribute | null = null;
|
||||
|
||||
for (const attr of attrs) {
|
||||
if (
|
||||
attr.type !== "JSXAttribute" ||
|
||||
!attr.name ||
|
||||
attr.name.type !== "JSXIdentifier"
|
||||
)
|
||||
continue;
|
||||
if (attr.name.name === "imageUploadsEnabled") imageUploadsAttr = attr;
|
||||
if (attr.name.name === "inputFileAccept") inputFileAcceptAttr = attr;
|
||||
if (attr.name.name === "attachments") existingAttachmentsAttr = attr;
|
||||
}
|
||||
|
||||
// Skip if neither deprecated prop is present
|
||||
if (!imageUploadsAttr && !inputFileAcceptAttr) return;
|
||||
// Skip if attachments prop already exists (already migrated or manual)
|
||||
if (existingAttachmentsAttr) return;
|
||||
|
||||
// Build the attachments object properties
|
||||
const properties = [];
|
||||
|
||||
if (imageUploadsAttr) {
|
||||
let enabledExpr;
|
||||
const val = imageUploadsAttr.value;
|
||||
if (!val) {
|
||||
// Shorthand: <CopilotChat imageUploadsEnabled /> means true
|
||||
enabledExpr = j.booleanLiteral(true);
|
||||
} else if (
|
||||
val.type === "JSXExpressionContainer" &&
|
||||
val.expression.type === "BooleanLiteral"
|
||||
) {
|
||||
enabledExpr = j.booleanLiteral(val.expression.value);
|
||||
} else if (val.type === "JSXExpressionContainer") {
|
||||
// Dynamic expression — preserve as-is
|
||||
enabledExpr = val.expression;
|
||||
} else {
|
||||
enabledExpr = j.booleanLiteral(true);
|
||||
}
|
||||
properties.push(j.objectProperty(j.identifier("enabled"), enabledExpr));
|
||||
}
|
||||
|
||||
if (inputFileAcceptAttr) {
|
||||
const val = inputFileAcceptAttr.value;
|
||||
if (val) {
|
||||
if (val.type === "StringLiteral") {
|
||||
properties.push(
|
||||
j.objectProperty(
|
||||
j.identifier("accept"),
|
||||
j.stringLiteral(val.value),
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
val.type === "JSXExpressionContainer" &&
|
||||
val.expression.type === "StringLiteral"
|
||||
) {
|
||||
properties.push(
|
||||
j.objectProperty(
|
||||
j.identifier("accept"),
|
||||
j.stringLiteral(val.expression.value),
|
||||
),
|
||||
);
|
||||
} else if (val.type === "JSXExpressionContainer") {
|
||||
// Dynamic expression — preserve as-is
|
||||
properties.push(
|
||||
j.objectProperty(j.identifier("accept"), val.expression),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (properties.length === 0) return;
|
||||
|
||||
// Create: attachments={{ enabled: true, accept: "..." }}
|
||||
const attachmentsAttr = j.jsxAttribute(
|
||||
j.jsxIdentifier("attachments"),
|
||||
j.jsxExpressionContainer(j.objectExpression(properties)),
|
||||
);
|
||||
|
||||
// Remove old props, add new one
|
||||
path.node.attributes = attrs.filter(
|
||||
(attr) => attr !== imageUploadsAttr && attr !== inputFileAcceptAttr,
|
||||
);
|
||||
path.node.attributes.push(attachmentsAttr);
|
||||
|
||||
changed = true;
|
||||
});
|
||||
|
||||
return changed ? root.toSource() : undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user