diff --git a/package-lock.json b/package-lock.json index 81146d08..c2d01ea7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -103,6 +103,7 @@ "@types/mustache": "^4.2.6", "@types/node": "^25.0.3", "@types/nodemailer": "^7.0.4", + "@vitest/browser-playwright": "^4.1.10", "autoprefixer": "^10.4.22", "clsx": "^2.1.1", "concurrently": "^9.2.1", @@ -127,7 +128,8 @@ "vite": "^7.2.2", "vite-node": "^5.3.0", "vite-plugin-devtools-json": "^1.0.0", - "vitest": "^4.1.10" + "vitest": "^4.1.10", + "vitest-browser-svelte": "^3.0.0" }, "engines": { "node": ">=20.0.0" @@ -158,6 +160,13 @@ "node": ">=6.9.0" } }, + "node_modules/@blazediff/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", + "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==", + "dev": true, + "license": "MIT" + }, "node_modules/@codemirror/autocomplete": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", @@ -2646,6 +2655,19 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@testing-library/svelte-core": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.1.3.tgz", + "integrity": "sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, "node_modules/@types/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", @@ -2919,6 +2941,63 @@ "@codemirror/view": ">=6.0.0" } }, + "node_modules/@vitest/browser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz", + "integrity": "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@blazediff/core": "1.9.1", + "@vitest/mocker": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.0", + "ws": "^8.19.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.10" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.10.tgz", + "integrity": "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "4.1.10", + "@vitest/mocker": "4.1.10", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, + "node_modules/@vitest/browser/node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/@vitest/expect": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", @@ -8470,6 +8549,56 @@ "node": ">=22.0.0" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pngjs": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", @@ -11046,6 +11175,23 @@ } } }, + "node_modules/vitest-browser-svelte": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/vitest-browser-svelte/-/vitest-browser-svelte-3.0.0.tgz", + "integrity": "sha512-BKM9iBGNsEQzigWuSZFAiw6AJDqQqMttApoD8gM8LTI6vxfd423rDMN8GqUHJyGbhH3XcOo9N9u/4hu47Nxhsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/svelte-core": "^1.1.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vitest": "^4.0.0" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -11221,6 +11367,28 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xmlbuilder": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz", diff --git a/package.json b/package.json index 95bb146e..ccc0e206 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "seed": "npx knex seed:run", "start": "node build/main.js", "test": "vitest run", + "test:client": "vitest run --project=client", "test:server": "vitest run --project=server", "test:watch": "vitest" }, @@ -72,6 +73,7 @@ "@types/mustache": "^4.2.6", "@types/node": "^25.0.3", "@types/nodemailer": "^7.0.4", + "@vitest/browser-playwright": "^4.1.10", "autoprefixer": "^10.4.22", "clsx": "^2.1.1", "concurrently": "^9.2.1", @@ -96,7 +98,8 @@ "vite": "^7.2.2", "vite-node": "^5.3.0", "vite-plugin-devtools-json": "^1.0.0", - "vitest": "^4.1.10" + "vitest": "^4.1.10", + "vitest-browser-svelte": "^3.0.0" }, "engines": { "node": ">=20.0.0" diff --git a/src/lib/components/CopyButton.svelte.test.ts b/src/lib/components/CopyButton.svelte.test.ts new file mode 100644 index 00000000..7ceb0a8e --- /dev/null +++ b/src/lib/components/CopyButton.svelte.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-svelte"; +import CopyButton from "./CopyButton.svelte"; + +// The clipboard is spied on rather than read back: headless Chromium does not +// grant clipboard-read permission, and the component's contract is "calls +// navigator.clipboard.writeText and flips to the copied state". +describe("CopyButton", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("copies the text prop and shows the copied state on click", async () => { + const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined); + + const screen = await render(CopyButton, { text: "copy-me" }); + const copiedTooltip = screen.getByText("Copied"); + + // Hidden before the click (class-based visibility) + await expect.element(copiedTooltip).toHaveClass(/opacity-0/); + + await screen.getByRole("button").click(); + + expect(writeText).toHaveBeenCalledExactlyOnceWith("copy-me"); + await expect.element(copiedTooltip).toHaveClass(/scale-100/); + }); + + it("invokes the onclick callback", async () => { + vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined); + const onclick = vi.fn(); + + const screen = await render(CopyButton, { text: "x", onclick }); + await screen.getByRole("button").click(); + + expect(onclick).toHaveBeenCalledOnce(); + }); + + it("does not touch the clipboard when no text is provided", async () => { + const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined); + + const screen = await render(CopyButton, {}); + await screen.getByRole("button").click(); + + expect(writeText).not.toHaveBeenCalled(); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 40e0408e..17e7b377 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,7 @@ import { sveltekit } from "@sveltejs/kit/vite"; import version from "vite-plugin-package-version"; import { defineConfig } from "vite"; import devtoolsJson from "vite-plugin-devtools-json"; +import { playwright } from "@vitest/browser-playwright"; import * as dotenv from "dotenv"; @@ -68,6 +69,23 @@ export default defineConfig(({ mode }) => { exclude: ["src/**/*.svelte.{test,spec}.{js,ts}"], }, }, + { + extends: "./vite.config.ts", + test: { + name: "client", + browser: { + enabled: true, + headless: true, + provider: playwright(), + instances: [{ browser: "chromium" }], + // Vitest's default browser API port (63315) can fall inside Windows + // Hyper-V excluded TCP port ranges; pin below the ephemeral range. + api: { port: 5180 }, + }, + include: ["src/**/*.svelte.{test,spec}.{js,ts}"], + setupFiles: ["./vitest-setup-client.ts"], + }, + }, ], }, }; diff --git a/vitest-setup-client.ts b/vitest-setup-client.ts new file mode 100644 index 00000000..bafa1c88 --- /dev/null +++ b/vitest-setup-client.ts @@ -0,0 +1,27 @@ +import { vi } from "vitest"; + +// Shared stubs for SvelteKit runtime modules used by components under test. +// Values are minimal but realistic; individual tests can override with their +// own vi.mock(...) when they need different page data. +vi.mock("$app/state", () => ({ + page: { + data: { + siteStatusColors: { UP: "#22c55e", DOWN: "#ef4444", DEGRADED: "#eab308", MAINTENANCE: "#3b82f6" }, + dateAndTimeFormat: { dateOnly: "yyyy-MM-dd", timeOnly: "HH:mm" }, + nowAtTz: 1768478400, // 2026-01-15T12:00:00Z + }, + url: new URL("http://localhost/"), + params: {}, + route: { id: null }, + status: 200, + error: null, + form: null, + state: {}, + }, +})); + +vi.mock("$app/paths", () => ({ + base: "", + assets: "", + resolve: (path: string) => path, +}));