diff --git a/package.json b/package.json
index 451dd9d984..86d6ea60af 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,9 @@
"@manypkg/cli": "^0.21.3",
"@playwright/test": "1.45.0",
"@prettier/sync": "^0.5.2",
+ "@testing-library/dom": "^10.4.0",
+ "@testing-library/jest-dom": "^6.4.8",
+ "@testing-library/react": "^16.0.0",
"@types/bun": "^1.1.5",
"@types/node": "^20.14.0",
"@uploadthing/eslint-config": "workspace:*",
diff --git a/packages/react/test/upload-button.test.tsx b/packages/react/test/upload-button.test.tsx
new file mode 100644
index 0000000000..69025b030b
--- /dev/null
+++ b/packages/react/test/upload-button.test.tsx
@@ -0,0 +1,229 @@
+// @vitest-environment happy-dom
+///
+
+import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
+import { http, HttpResponse } from "msw";
+import { setupServer } from "msw/node";
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vitest";
+
+import {
+ createRouteHandler,
+ createUploadthing,
+ extractRouterConfig,
+} from "uploadthing/server";
+
+import { generateUploadButton } from "../src";
+
+const noop = vi.fn();
+
+const f = createUploadthing();
+const testRouter = {
+ image: f({ image: {} }).onUploadComplete(noop),
+ audio: f({ audio: {} }).onUploadComplete(noop),
+ pdf: f({ "application/pdf": {} }).onUploadComplete(noop),
+ multi: f({ image: { maxFileCount: 4 } }).onUploadComplete(noop),
+};
+const routeHandler = createRouteHandler({ router: testRouter });
+const UploadButton = generateUploadButton();
+
+const utGet = vi.fn<[Request]>();
+const utPost = vi.fn<[{ request: Request; body: any }]>();
+const server = setupServer(
+ http.get("/api/uploadthing", ({ request }) => {
+ utGet(request);
+ return routeHandler.GET(request);
+ }),
+ http.post("/api/uploadthing", async ({ request }) => {
+ const body = await request.json();
+ utPost({ request, body });
+ return HttpResponse.json([
+ // empty array, we're not testing the upload endpoint here
+ // we have other tests for that...
+ ]);
+ }),
+);
+
+beforeAll(() => server.listen());
+afterEach(() => {
+ server.resetHandlers();
+ cleanup();
+});
+afterAll(() => server.close());
+
+/**
+ * This is a basic suite of tests for the UploadButton component.
+ * This is not meant to be a comprehensive test suite, but rather a
+ * basic check of core functionality.
+ *
+ * In the future, the goal is to use these and other tests to ensure
+ * consistency across the components in alll of the supported libraries
+ * (React, Solid, Svelte, Vue, etc.). Ideally core test logic should be
+ * shared as much as possible, so that we don;t have to maintain the test
+ * implementations in addition to the component implementations.
+ *
+ * In #886, we attempted to bring all of the libraries in line with each
+ * other, and at that time we manually went verified the following:
+ * - components all accept the same props/have similar APIs
+ * - styles match across libraries in each component state
+ * - copy matches across libraries
+ * - components are reactive
+ * - selecting files changes the text on the button
+ * - uploading changes style and text on button
+ * - paste works
+ * - abort works
+ *
+ * Some other items we did not check but probably should have:
+ * - functions are triggered at the right times
+ * - onChange should occur on select, clear, and drop
+ * - custom styling overrides are available and functioning (and always apply
+ * to the same parts of the component)
+ */
+
+describe("UploadButton - basic", () => {
+ it("fetches and displays route config", async () => {
+ const utils = render();
+ const label = utils.container.querySelector("label");
+
+ // Previously, when component was disabled, it would show "Loading..."
+ // expect(label).toHaveTextContent("Loading...");
+
+ // then eventually we load in the data, and we should be in the ready state
+ await waitFor(() => expect(label).toHaveAttribute("data-state", "ready"));
+ expect(label).toHaveTextContent("Choose File");
+
+ expect(utGet).toHaveBeenCalledOnce();
+ expect(utils.getByText("Image (4MB)")).toBeInTheDocument();
+ });
+
+ it("picks up route config from global and skips fetch", async () => {
+ (window as any).__UPLOADTHING = extractRouterConfig(testRouter);
+
+ const utils = render();
+ expect(utils.getByText("Image (4MB)")).toBeInTheDocument();
+ expect(utGet).not.toHaveBeenCalled();
+
+ delete (window as any).__UPLOADTHING;
+ });
+
+ it("requests URLs when a file is selected", async () => {
+ const utils = render();
+ const label = utils.container.querySelector("label");
+ await waitFor(() => expect(label).toHaveAttribute("data-state", "ready"));
+
+ fireEvent.change(utils.getByLabelText("Choose File"), {
+ target: { files: [new File(["foo"], "foo.txt", { type: "text/plain" })] },
+ });
+ await waitFor(() => {
+ expect(utPost).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: {
+ files: [{ name: "foo.txt", type: "text/plain", size: 3 }],
+ },
+ }),
+ );
+ });
+ });
+
+ it("manual mode requires extra click", async () => {
+ const utils = render(
+ ,
+ );
+ const label = utils.container.querySelector("label");
+ await waitFor(() => expect(label).toHaveAttribute("data-state", "ready"));
+
+ fireEvent.change(utils.getByLabelText("Choose File"), {
+ target: { files: [new File([""], "foo.txt")] },
+ });
+ expect(label).toHaveTextContent("Upload 1 file");
+
+ fireEvent.click(label!);
+ await waitFor(() => {
+ expect(utPost).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: {
+ files: [expect.objectContaining({ name: "foo.txt" })],
+ },
+ }),
+ );
+ });
+ });
+
+ // https://discord.com/channels/966627436387266600/1102510616326967306/1267098160468197409
+ it.skip("disabled", async () => {
+ const utils = render();
+ const label = utils.container.querySelector("label");
+ await waitFor(() => expect(label).toHaveTextContent("Choose File"));
+ expect(label).toHaveAttribute("disabled");
+ });
+});
+
+describe("UploadButton - lifecycle hooks", () => {
+ it("onBeforeUploadBegin alters the requested files", async () => {
+ const utils = render(
+ {
+ return [new File([""], "bar.txt")];
+ }}
+ />,
+ );
+ await waitFor(() => {
+ expect(utils.getByText("Choose File")).toBeInTheDocument();
+ });
+
+ fireEvent.change(utils.getByLabelText("Choose File"), {
+ target: { files: [new File([""], "foo.txt")] },
+ });
+ await waitFor(() => {
+ expect(utPost).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: {
+ files: [expect.objectContaining({ name: "bar.txt" })],
+ },
+ }),
+ );
+ });
+ });
+});
+
+describe("UploadButton - Theming", () => {
+ it("renders custom styles", async () => {
+ const utils = render(
+ ,
+ );
+ await waitFor(() => {
+ expect(utils.getByText("Choose File")).toHaveStyle({
+ backgroundColor: "red",
+ });
+ });
+ });
+});
+
+describe("UploadButton - Content Customization", () => {
+ it("renders custom content", async () => {
+ const utils = render(
+ ,
+ );
+ await waitFor(() => {
+ expect(utils.getByText("Allowed content")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts
index 48b09a3fad..8d2e96948f 100644
--- a/packages/react/vitest.config.ts
+++ b/packages/react/vitest.config.ts
@@ -1 +1,9 @@
-export { default } from "../../vitest.config";
+import { mergeConfig } from "vitest/config";
+
+import baseConfig from "../../vitest.config";
+
+export default mergeConfig(baseConfig, {
+ test: {
+ setupFiles: ["@testing-library/jest-dom/vitest"],
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 87d9c23e12..080724c706 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -37,6 +37,15 @@ importers:
'@prettier/sync':
specifier: ^0.5.2
version: 0.5.2(prettier@3.3.2)
+ '@testing-library/dom':
+ specifier: ^10.4.0
+ version: 10.4.0
+ '@testing-library/jest-dom':
+ specifier: ^6.4.8
+ version: 6.4.8
+ '@testing-library/react':
+ specifier: ^16.0.0
+ version: 16.0.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/bun':
specifier: ^1.1.5
version: 1.1.5
@@ -516,7 +525,7 @@ importers:
version: 0.12.5(solid-js@1.8.16)
'@solidjs/start':
specifier: ^0.6.0
- version: 0.6.1(@testing-library/jest-dom@6.4.5(@types/bun@1.1.5)(vitest@1.6.0(@types/node@20.14.0)(happy-dom@13.10.1)(lightningcss@1.24.1)(terser@5.19.2)))(rollup@4.18.0)(solid-js@1.8.16)(vinxi@0.3.11(@types/node@20.14.0)(encoding@0.1.13)(ioredis@5.3.2)(lightningcss@1.24.1)(magicast@0.3.4)(terser@5.19.2))(vite@5.3.1(@types/node@20.14.0)(lightningcss@1.24.1)(terser@5.19.2))
+ version: 0.6.1(@testing-library/jest-dom@6.4.8)(rollup@4.18.0)(solid-js@1.8.16)(vinxi@0.3.11(@types/node@20.14.0)(encoding@0.1.13)(ioredis@5.3.2)(lightningcss@1.24.1)(magicast@0.3.4)(terser@5.19.2))(vite@5.3.1(@types/node@20.14.0)(lightningcss@1.24.1)(terser@5.19.2))
'@uploadthing/solid':
specifier: 6.5.3
version: link:../../packages/solid
@@ -5531,6 +5540,10 @@ packages:
resolution: {integrity: sha512-wdsYKy5zupPyLCW2Je5DLHSxSfbIp6h80WoHOQc+RPtmPGA52O9x5MJEkv92Sjonpq+poOAtUKhh1kBGAXBrNA==}
engines: {node: '>=18'}
+ '@testing-library/dom@10.4.0':
+ resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==}
+ engines: {node: '>=18'}
+
'@testing-library/jest-dom@6.4.5':
resolution: {integrity: sha512-AguB9yvTXmCnySBP1lWjfNNUwpbElsaQ567lt2VdGqAdHtpieLgjmcVyv1q7PMIvLbgpDdkWV5Ydv3FEejyp2A==}
engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
@@ -5552,6 +5565,25 @@ packages:
vitest:
optional: true
+ '@testing-library/jest-dom@6.4.8':
+ resolution: {integrity: sha512-JD0G+Zc38f5MBHA4NgxQMR5XtO5Jx9g86jqturNTt2WUfRmLDIY7iKkWHDCCTiDuFMre6nxAD5wHw9W5kI4rGw==}
+ engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
+
+ '@testing-library/react@16.0.0':
+ resolution: {integrity: sha512-guuxUKRWQ+FgNX0h0NS0FIq3Q3uLtWVpBzcLOggmfMoUpgBnzBzvLLd4fbm6yS8ydJd94cIfY4yP9qUQjM2KwQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@testing-library/dom': ^10.0.0
+ '@types/react': ^18.0.0
+ '@types/react-dom': ^18.0.0
+ react: ^18.0.0
+ react-dom: ^18.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@testing-library/user-event@14.5.2':
resolution: {integrity: sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==}
engines: {node: '>=12', npm: '>=6'}
@@ -20214,7 +20246,7 @@ snapshots:
dependencies:
solid-js: 1.8.16
- '@solidjs/start@0.6.1(@testing-library/jest-dom@6.4.5(@types/bun@1.1.5)(vitest@1.6.0(@types/node@20.14.0)(happy-dom@13.10.1)(lightningcss@1.24.1)(terser@5.19.2)))(rollup@4.18.0)(solid-js@1.8.16)(vinxi@0.3.11(@types/node@20.14.0)(encoding@0.1.13)(ioredis@5.3.2)(lightningcss@1.24.1)(magicast@0.3.4)(terser@5.19.2))(vite@5.3.1(@types/node@20.14.0)(lightningcss@1.24.1)(terser@5.19.2))':
+ '@solidjs/start@0.6.1(@testing-library/jest-dom@6.4.8)(rollup@4.18.0)(solid-js@1.8.16)(vinxi@0.3.11(@types/node@20.14.0)(encoding@0.1.13)(ioredis@5.3.2)(lightningcss@1.24.1)(magicast@0.3.4)(terser@5.19.2))(vite@5.3.1(@types/node@20.14.0)(lightningcss@1.24.1)(terser@5.19.2))':
dependencies:
'@vinxi/plugin-directives': 0.3.1(vinxi@0.3.11(@types/node@20.14.0)(encoding@0.1.13)(ioredis@5.3.2)(lightningcss@1.24.1)(magicast@0.3.4)(terser@5.19.2))
'@vinxi/server-components': 0.3.3(vinxi@0.3.11(@types/node@20.14.0)(encoding@0.1.13)(ioredis@5.3.2)(lightningcss@1.24.1)(magicast@0.3.4)(terser@5.19.2))
@@ -20229,7 +20261,7 @@ snapshots:
source-map-js: 1.2.0
terracotta: 1.0.5(solid-js@1.8.16)
vite-plugin-inspect: 0.7.38(rollup@4.18.0)(vite@5.3.1(@types/node@20.14.0)(lightningcss@1.24.1)(terser@5.19.2))
- vite-plugin-solid: 2.9.1(@testing-library/jest-dom@6.4.5(@types/bun@1.1.5)(vitest@1.6.0(@types/node@20.14.0)(happy-dom@13.10.1)(lightningcss@1.24.1)(terser@5.19.2)))(solid-js@1.8.16)(vite@5.3.1(@types/node@20.14.0)(lightningcss@1.24.1)(terser@5.19.2))
+ vite-plugin-solid: 2.9.1(@testing-library/jest-dom@6.4.8)(solid-js@1.8.16)(vite@5.3.1(@types/node@20.14.0)(lightningcss@1.24.1)(terser@5.19.2))
transitivePeerDependencies:
- '@nuxt/kit'
- '@testing-library/jest-dom'
@@ -20248,7 +20280,7 @@ snapshots:
'@storybook/csf': 0.1.11
'@types/cross-spawn': 6.0.6
cross-spawn: 7.0.3
- globby: 14.0.1
+ globby: 14.0.2
jscodeshift: 0.15.2(@babel/preset-env@7.24.6(@babel/core@7.24.4))
lodash: 4.17.21
prettier: 3.3.2
@@ -20271,7 +20303,7 @@ snapshots:
process: 0.11.10
recast: 0.23.9
util: 0.12.5
- ws: 8.17.1
+ ws: 8.18.0
transitivePeerDependencies:
- bufferutil
- supports-color
@@ -20542,6 +20574,17 @@ snapshots:
lz-string: 1.5.0
pretty-format: 27.5.1
+ '@testing-library/dom@10.4.0':
+ dependencies:
+ '@babel/code-frame': 7.24.6
+ '@babel/runtime': 7.24.4
+ '@types/aria-query': 5.0.4
+ aria-query: 5.3.0
+ chalk: 4.1.2
+ dom-accessibility-api: 0.5.16
+ lz-string: 1.5.0
+ pretty-format: 27.5.1
+
'@testing-library/jest-dom@6.4.5(@types/bun@1.1.5)(vitest@1.6.0(@types/node@20.14.0)(happy-dom@13.10.1)(lightningcss@1.24.1)(terser@5.19.2))':
dependencies:
'@adobe/css-tools': 4.4.0
@@ -20556,6 +20599,27 @@ snapshots:
'@types/bun': 1.1.5
vitest: 1.6.0(@types/node@20.14.0)(happy-dom@13.10.1)(lightningcss@1.24.1)(terser@5.19.2)
+ '@testing-library/jest-dom@6.4.8':
+ dependencies:
+ '@adobe/css-tools': 4.4.0
+ '@babel/runtime': 7.24.4
+ aria-query: 5.3.0
+ chalk: 3.0.0
+ css.escape: 1.5.1
+ dom-accessibility-api: 0.6.3
+ lodash: 4.17.21
+ redent: 3.0.0
+
+ '@testing-library/react@16.0.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@babel/runtime': 7.24.4
+ '@testing-library/dom': 10.4.0
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ optionalDependencies:
+ '@types/react': 18.3.3
+ '@types/react-dom': 18.3.0
+
'@testing-library/user-event@14.5.2(@testing-library/dom@10.1.0)':
dependencies:
'@testing-library/dom': 10.1.0
@@ -24551,7 +24615,7 @@ snapshots:
enhanced-resolve: 5.15.0
mlly: 1.7.1
pathe: 1.1.2
- ufo: 1.5.3
+ ufo: 1.5.4
fake-indexeddb@5.0.2: {}
@@ -28442,7 +28506,7 @@ snapshots:
execa: 8.0.1
pathe: 1.1.2
pkg-types: 1.1.3
- ufo: 1.5.3
+ ufo: 1.5.4
oauth4webapi@2.10.4: {}
@@ -30725,13 +30789,13 @@ snapshots:
find-up: 5.0.0
fs-extra: 11.2.0
giget: 1.2.3
- globby: 14.0.1
+ globby: 14.0.2
jscodeshift: 0.15.2(@babel/preset-env@7.24.6(@babel/core@7.24.4))
leven: 3.1.0
ora: 5.4.1
prettier: 3.3.2
prompts: 2.4.2
- semver: 7.6.2
+ semver: 7.6.3
strip-json-comments: 3.1.1
tempy: 3.1.0
tiny-invariant: 1.3.3
@@ -31593,7 +31657,7 @@ snapshots:
mime: 3.0.0
node-fetch-native: 1.6.4
pathe: 1.1.2
- ufo: 1.5.3
+ ufo: 1.5.4
unenv@1.9.0:
dependencies:
@@ -32233,7 +32297,7 @@ snapshots:
- rollup
- supports-color
- vite-plugin-solid@2.9.1(@testing-library/jest-dom@6.4.5(@types/bun@1.1.5)(vitest@1.6.0(@types/node@20.14.0)(happy-dom@13.10.1)(lightningcss@1.24.1)(terser@5.19.2)))(solid-js@1.8.16)(vite@5.3.1(@types/node@20.14.0)(lightningcss@1.24.1)(terser@5.19.2)):
+ vite-plugin-solid@2.9.1(@testing-library/jest-dom@6.4.8)(solid-js@1.8.16)(vite@5.3.1(@types/node@20.14.0)(lightningcss@1.24.1)(terser@5.19.2)):
dependencies:
'@babel/core': 7.24.4
'@types/babel__core': 7.20.5
@@ -32244,7 +32308,7 @@ snapshots:
vite: 5.3.1(@types/node@20.14.0)(lightningcss@1.24.1)(terser@5.19.2)
vitefu: 0.2.5(vite@5.3.1(@types/node@20.14.0)(lightningcss@1.24.1)(terser@5.19.2))
optionalDependencies:
- '@testing-library/jest-dom': 6.4.5(@types/bun@1.1.5)(vitest@1.6.0(@types/node@20.14.0)(happy-dom@13.10.1)(lightningcss@1.24.1)(terser@5.19.2))
+ '@testing-library/jest-dom': 6.4.8
transitivePeerDependencies:
- supports-color