diff --git a/spec/javascript/channels/consumer_spec.ts b/spec/javascript/channels/consumer_spec.ts
new file mode 100644
index 000000000..ccfee3749
--- /dev/null
+++ b/spec/javascript/channels/consumer_spec.ts
@@ -0,0 +1,6 @@
+import {expect, it} from "vitest";
+import consumer from "channels/consumer";
+
+it("is defined", () => {
+ expect(consumer.url).toBe("ws://test.host/cable");
+});
diff --git a/spec/javascript/controllers/dialog_controller_spec.ts b/spec/javascript/controllers/dialog_controller_spec.ts
new file mode 100644
index 000000000..35954f6ad
--- /dev/null
+++ b/spec/javascript/controllers/dialog_controller_spec.ts
@@ -0,0 +1,10 @@
+import {expect, it} from "vitest";
+import {bootStimulus} from "spec/javascript/support/stimulus";
+import DialogController from "controllers/dialog_controller";
+
+it("updates the text content of its element", async () => {
+ document.body.innerHTML = "
";
+ await bootStimulus("dialog", DialogController);
+
+ expect(document.body.textContent).toBe("Hello World!");
+});
diff --git a/spec/javascript/controllers/hotkeys_controller_spec.ts b/spec/javascript/controllers/hotkeys_controller_spec.ts
new file mode 100644
index 000000000..c4654def5
--- /dev/null
+++ b/spec/javascript/controllers/hotkeys_controller_spec.ts
@@ -0,0 +1,74 @@
+import {describe, expect, it, vi} from "vitest";
+import {bootStimulus, getController} from "spec/javascript/support/stimulus";
+import HotkeysController from "controllers/hotkeys_controller";
+import {assert} from "javascript/helpers";
+
+function setupDOM(): void {
+ document.body.innerHTML = `
+
+
+
+ `;
+}
+
+async function setupController(): Promise {
+ setupDOM();
+
+ await bootStimulus("hotkeys", HotkeysController);
+}
+
+function element(): HTMLElement {
+ const selector = "[data-controller='hotkeys']";
+
+ return assert(document.querySelector(selector));
+}
+
+function controller(): HotkeysController {
+ return getController(element(), "hotkeys", HotkeysController);
+}
+
+function button(): HTMLButtonElement {
+ const selector = "button[data-hotkeys-target='click']";
+
+ return assert(document.querySelector(selector));
+}
+
+describe("clickTargetConnected", () => {
+ it("indexes the connected click target by its hotkey", async () => {
+ await setupController();
+
+ expect(controller().indexedClickTargets.get("a")).toBe(button());
+ });
+});
+
+describe("clickTargetDisconnected", () => {
+ it("removes the disconnected click target from the index", async () => {
+ await setupController();
+
+ button().remove();
+
+ await Promise.resolve();
+
+ expect(controller().indexedClickTargets.get("a")).toBeUndefined();
+ });
+});
+
+describe("handleKeydown", () => {
+ it("clicks the target for the pressed key", async () => {
+ await setupController();
+ const clickSpy = vi.spyOn(button(), "click");
+
+ controller().handleKeydown(new KeyboardEvent("keydown", {key: "a"}));
+
+ expect(clickSpy).toHaveBeenCalledWith();
+ });
+
+ it("does nothing if there is no target for the pressed key", async () => {
+ await setupController();
+ const clickSpy = vi.spyOn(button(), "click");
+
+ controller().handleKeydown(new KeyboardEvent("keydown", {key: "b"}));
+
+ expect(clickSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/spec/javascript/helpers/assert_spec.ts b/spec/javascript/helpers/assert_spec.ts
new file mode 100644
index 000000000..233d83557
--- /dev/null
+++ b/spec/javascript/helpers/assert_spec.ts
@@ -0,0 +1,33 @@
+import {describe, expect, it} from "vitest";
+import {assert} from "javascript/helpers";
+
+describe("assert", () => {
+ it("throws an error when the passed value is null", () => {
+ expect(() => { assert(null); }).toThrow("value is null or undefined");
+ });
+
+ it("throws an error when the passed value is undefined", () => {
+ expect(() => { assert(undefined); }).toThrow("value is null or undefined");
+ });
+
+ it("does not throw an error when the passed value is 0", () => {
+ expect(() => { assert(0); }).not.toThrow();
+ });
+
+ it("does not throw an error when the passed value is false", () => {
+ expect(() => { assert(false); }).not.toThrow();
+ });
+
+ it("does not throw an error when the passed value is empty string", () => {
+ expect(() => { assert(""); }).not.toThrow();
+ });
+
+ it("returns the passed value", () => {
+ expect(assert("blah")).toBe("blah");
+ expect(assert(0)).toBe(0);
+
+ const obj = {bloo: "blah"};
+
+ expect(assert(obj)).toBe(obj);
+ });
+});
diff --git a/spec/javascript/support/stimulus.ts b/spec/javascript/support/stimulus.ts
new file mode 100644
index 000000000..be29ca49b
--- /dev/null
+++ b/spec/javascript/support/stimulus.ts
@@ -0,0 +1,46 @@
+import {afterEach} from "vitest";
+import type {Context, Controller} from "@hotwired/stimulus";
+import {Application} from "@hotwired/stimulus";
+
+import {assert} from "javascript/helpers";
+
+let application: Application | null = null;
+
+type ControllerClass = new (context: Context) => T;
+
+async function bootStimulus(
+ name: string,
+ controller: ControllerClass,
+): Promise {
+ application ??= Application.start();
+
+ application.register(name, controller);
+ application.handleError = (error: Error): void => { throw error; };
+
+ await Promise.resolve();
+}
+
+function getController(
+ element: HTMLElement,
+ name: string,
+ controllerClass: ControllerClass,
+): T {
+ const controller =
+ assert(application).getControllerForElementAndIdentifier(element, name);
+
+ if (controller instanceof controllerClass) {
+ return controller;
+ } else if (controller) {
+ throw new Error("Controller class does not match");
+ }
+
+ throw new Error("Controller not found");
+}
+
+afterEach(() => {
+ if (application) { application.stop(); }
+
+ application = null;
+});
+
+export {bootStimulus, getController};
diff --git a/spec/javascript/vitest-globals.d.ts b/spec/javascript/vitest-globals.d.ts
new file mode 100644
index 000000000..9896c472f
--- /dev/null
+++ b/spec/javascript/vitest-globals.d.ts
@@ -0,0 +1 @@
+///
diff --git a/tsconfig.json b/tsconfig.json
index 0446d3a36..81a6a4a58 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -23,6 +23,7 @@
"_test_helpers/*": ["spec/javascript/_test_helpers/*"],
"controllers/*": ["app/javascript/controllers/*"]
},
+ "skipLibCheck": true,
"sourceMap": true,
"strict": true,
"target": "es6",