test: add Vitest browser-mode client project with CopyButton test

This commit is contained in:
Florian Kostenzer
2026-07-13 22:52:18 +02:00
parent 048518f2a8
commit e8008671d9
5 changed files with 264 additions and 2 deletions
+169 -1
View File
@@ -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",
+4 -1
View File
@@ -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"
@@ -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();
});
});
+18
View File
@@ -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"],
},
},
],
},
};
+27
View File
@@ -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,
}));