Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ jobs:
name: Verify ESLint Autogen
command: bundle exec exe/eslint_autogen

- run:
name: Vitest
command: yarn vitest run --coverage

- run:
name: Brakeman
command: bundle exec brakeman
Expand Down
20 changes: 14 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,30 +20,38 @@
"@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.2",
"@stylistic/eslint-plugin": "^5.7.0",
"@types/rails__actioncable": "^6.1.11",
"esbuild": "^0.25.0",
"@types/hotwired__turbo": "^8.0.5",
"@types/node": "^25.0.3",
"@types/rails__actioncable": "^8.0.3",
"@typescript-eslint/eslint-plugin": "^8.50.0",
"@typescript-eslint/parser": "^8.50.0",
"@vitest/coverage-v8": "^4.0.18",
"esbuild": "^0.27.1",
"eslint": "^9.39.2",
"eslint-find-rules": "^5.0.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-sort-keys-fix": "^1.1.2",
"eslint-plugin-vitest": "^0.5.4",
"globals": "^17.0.0",
"jiti": "^2.6.1",
"jsdom": "^28.0.0",
"postcss": "^8.5.6",
"stylelint": "^17.0.0",
"stylelint-config-property-sort-order-smacss": "^11.0.0",
"stylelint-config-standard-scss": "^17.0.0",
"stylelint-selector-bem-pattern": "^4.0.1",
"typescript": "^5.8.3",
"typescript-eslint": "^8.55.0"
"typescript": "^5.9.3",
"typescript-eslint": "^8.55.0",
"vitest": "^4.0.18"
},
"scripts": {
"build": "esbuild app/javascript/application.ts --bundle --sourcemap --format=iife --outdir=app/assets/builds --public-path=/assets",
"build:watch": "esbuild app/javascript/application.js --bundle --sourcemap --format=iife --outdir=app/assets/builds --public-path=/assets --watch",
"build": "esbuild app/javascript/*.* --bundle --sourcemap --format=iife --outdir=app/assets/builds --public-path=/assets",
"eslint": "eslint ./ --cache --max-warnings=0",
"eslint_find_unused_rules": "eslint-find-rules --unused --flatConfig --no-core eslint.config.mts",
"pretest": "yarn tscheck && yarn eslint",
"stylelint": "./node_modules/stylelint/bin/stylelint.mjs 'app/assets/stylesheets/**/*'",
"test": "yarn vitest run --coverage",
"tscheck": "yarn tsc --noEmit"
}
}
6 changes: 6 additions & 0 deletions spec/javascript/application_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import {expect, it} from "vitest";
import "javascript/application";

it("disables Turbo", () => {
expect(Turbo.session.drive).toBe(false);
});
6 changes: 6 additions & 0 deletions spec/javascript/channels/consumer_spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
10 changes: 10 additions & 0 deletions spec/javascript/controllers/dialog_controller_spec.ts
Original file line number Diff line number Diff line change
@@ -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 = "<div data-controller='dialog'></div>";
await bootStimulus("dialog", DialogController);

expect(document.body.textContent).toBe("Hello World!");
});
74 changes: 74 additions & 0 deletions spec/javascript/controllers/hotkeys_controller_spec.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<div data-controller="hotkeys">
<button data-hotkeys-target="click" data-hotkey="a">A</button>
</div>
`;
}

async function setupController(): Promise<void> {
setupDOM();

await bootStimulus("hotkeys", HotkeysController);
}

function element(): HTMLElement {
const selector = "[data-controller='hotkeys']";

return assert(document.querySelector<HTMLElement>(selector));
}

function controller(): HotkeysController {
return getController(element(), "hotkeys", HotkeysController);
}

function button(): HTMLButtonElement {
const selector = "button[data-hotkeys-target='click']";

return assert(document.querySelector<HTMLButtonElement>(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();
});
});
33 changes: 33 additions & 0 deletions spec/javascript/helpers/assert_spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
46 changes: 46 additions & 0 deletions spec/javascript/support/stimulus.ts
Original file line number Diff line number Diff line change
@@ -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<T> = new (context: Context) => T;

async function bootStimulus<T extends Controller>(
name: string,
controller: ControllerClass<T>,
): Promise<void> {
application ??= Application.start();

application.register(name, controller);
application.handleError = (error: Error): void => { throw error; };

await Promise.resolve();
}

function getController<T extends Controller>(
element: HTMLElement,
name: string,
controllerClass: ControllerClass<T>,
): 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};
5 changes: 5 additions & 0 deletions spec/javascript/test_helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import {beforeEach, expect} from "vitest";

beforeEach(() => {
expect.hasAssertions();
});
47 changes: 47 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {defineConfig} from "vitest/config";
import path from "node:path";

const root = import.meta.dirname;

function appPath(subpath: string): string {
return `${path.resolve(root, "app/javascript", subpath)}/`;
}

export default defineConfig({
resolve: {
alias: [
{find: /^channels\//u, replacement: appPath("channels")},
{find: /^controllers\//u, replacement: appPath("controllers")},
{find: /^javascript\//u, replacement: appPath("")},
{find: /^spec\//u, replacement: `${path.resolve(root, "spec")}/`},
],
},
test: {
coverage: {
exclude: ["app/javascript/@types/**"],
include: ["app/javascript/**/*.ts"],
provider: "v8",
reportsDirectory: "coverage/vitest",
thresholds: {
branches: 100,
functions: 100,
lines: 0,
statements: 0,
},
},
environment: "jsdom",
environmentOptions: {
jsdom: {
url: "http://test.host",
},
},
include: ["spec/javascript/**/*_spec.ts"],
outputFile: {
junit: "/tmp/test-results/junit.xml",
},
reporters: ["default", "junit"],
restoreMocks: true,
root: ".",
setupFiles: ["spec/javascript/test_helper.ts"],
},
});
Loading