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