Hs2module
cmsj/Hammerspoon2
Ensure all Hammerspoon v2 modules follow established patterns
Guide for writing tests in Hammerspoon 2 — framework, structure, JSTestHarness patterns, async, environment guards, and what not to test
$ npx skills add cmsj/Hammerspoon2 --skill hs2tests -a claude-codeProject install by default; add -g for ~/.claude/skills/.
$ gh skill install cmsj/Hammerspoon2 hs2tests --agent claude-codeProject scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).
$ git clone --depth 1 https://github.com/cmsj/Hammerspoon2.git skills-src && mkdir -p .claude/skills && cp -r skills-src/.claude/skills/HSTests .claude/skills/hs2tests && rm -rf skills-srcUse ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.
Claude Code skills documentation · loads skills from .claude/skills/
Install the "hs2tests" agent skill from https://github.com/cmsj/Hammerspoon2/tree/main/.claude/skills/HSTests into .claude/skills/hs2tests/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "hs2tests", then confirm the skill loads.Claude Code copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$skill-installer install https://github.com/cmsj/Hammerspoon2/tree/main/.claude/skills/HSTestsType this inside Codex. $skill-installer <name> installs a curated skill from openai/skills. The installer writes to $CODEX_HOME/skills (default ~/.codex/skills). Restart Codex if the skill does not show up.
$ npx skills add cmsj/Hammerspoon2 --skill hs2tests -a codexProject install goes to .agents/skills/; add -g for ~/.codex/skills/.
$ gh skill install cmsj/Hammerspoon2 hs2tests --agent codexProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/cmsj/Hammerspoon2.git skills-src && mkdir -p .agents/skills && cp -r skills-src/.claude/skills/HSTests .agents/skills/hs2tests && rm -rf skills-srcUse ~/.agents/skills/ instead of .agents/skills for a personal install.
Codex skills documentation · loads skills from .agents/skills/
Install the "hs2tests" agent skill from https://github.com/cmsj/Hammerspoon2/tree/main/.claude/skills/HSTests into .agents/skills/hs2tests/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "hs2tests", then confirm the skill loads.Codex copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add cmsj/Hammerspoon2 --skill hs2tests -a cursorProject install goes to .agents/skills/; add -g for ~/.cursor/skills/.
$ gh skill install cmsj/Hammerspoon2 hs2tests --agent cursorProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/cmsj/Hammerspoon2.git skills-src && mkdir -p .cursor/skills && cp -r skills-src/.claude/skills/HSTests .cursor/skills/hs2tests && rm -rf skills-srcUse ~/.cursor/skills/ instead of .cursor/skills for a personal install.
Cursor skills documentation · loads skills from .cursor/skills/, .agents/skills/, .claude/skills/, .codex/skills/
Install the "hs2tests" agent skill from https://github.com/cmsj/Hammerspoon2/tree/main/.claude/skills/HSTests into .cursor/skills/hs2tests/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "hs2tests", then confirm the skill loads.Cursor copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gemini skills install https://github.com/cmsj/Hammerspoon2.git --path .claude/skills/HSTests--scope user (default) or --scope workspace; --path is the subfolder of the repo that holds the skill; --consent skips the security confirmation prompt.
$ npx skills add cmsj/Hammerspoon2 --skill hs2tests -a gemini-cliProject install goes to .agents/skills/; add -g for ~/.gemini/skills/.
$ gh skill install cmsj/Hammerspoon2 hs2tests --agent gemini-cliProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/cmsj/Hammerspoon2.git skills-src && mkdir -p .gemini/skills && cp -r skills-src/.claude/skills/HSTests .gemini/skills/hs2tests && rm -rf skills-srcUse ~/.gemini/skills/ instead of .gemini/skills for a personal install, then run /skills reload.
Gemini CLI skills documentation · loads skills from .gemini/skills/, .agents/skills/
Install the "hs2tests" agent skill from https://github.com/cmsj/Hammerspoon2/tree/main/.claude/skills/HSTests into .gemini/skills/hs2tests/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "hs2tests", then confirm the skill loads.Gemini CLI copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gh skill install cmsj/Hammerspoon2 hs2testsInstalls for Copilot at project scope by default; add --scope user for a personal install. Preview a skill first with gh skill preview. Needs GitHub CLI 2.90.0 or later (public preview).
$ npx skills add cmsj/Hammerspoon2 --skill hs2tests -a github-copilotProject install goes to .agents/skills/; add -g for ~/.copilot/skills/.
$ git clone --depth 1 https://github.com/cmsj/Hammerspoon2.git skills-src && mkdir -p .github/skills && cp -r skills-src/.claude/skills/HSTests .github/skills/hs2tests && rm -rf skills-srcUse ~/.copilot/skills/ instead of .github/skills for a personal install. Commit .github/skills so cloud agent and code review can use it.
GitHub Copilot skills documentation · loads skills from .github/skills/, .claude/skills/, .agents/skills/
Install the "hs2tests" agent skill from https://github.com/cmsj/Hammerspoon2/tree/main/.claude/skills/HSTests into .github/skills/hs2tests/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "hs2tests", then confirm the skill loads.GitHub Copilot copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add cmsj/Hammerspoon2 --skill hs2tests -a opencodeOpenCode documents no install command of its own. Project install goes to .agents/skills/; add -g for ~/.config/opencode/skills/.
$ gh skill install cmsj/Hammerspoon2 hs2tests --agent opencodeProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/cmsj/Hammerspoon2.git skills-src && mkdir -p .opencode/skills && cp -r skills-src/.claude/skills/HSTests .opencode/skills/hs2tests && rm -rf skills-srcUse ~/.config/opencode/skills/ instead of .opencode/skills for a personal install.
OpenCode skills documentation · loads skills from .opencode/skills/, .claude/skills/, .agents/skills/
Install the "hs2tests" agent skill from https://github.com/cmsj/Hammerspoon2/tree/main/.claude/skills/HSTests into .opencode/skills/hs2tests/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "hs2tests", then confirm the skill loads.OpenCode copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
hs2testsGuide for writing tests in Hammerspoon 2 — framework, structure, JSTestHarness patterns, async, environment guards, and what not to test
Hs2tests is an agent skill from cmsj/Hammerspoon2. Guide for writing tests in Hammerspoon 2 — framework, structure, JSTestHarness patterns, async, environment guards, and what not to test
Its SKILL.md is about 5.4k tokens, which your agent loads only when the skill is triggered. It is a single SKILL.md file with no bundled scripts.
The licence is MIT.
Read from SKILL.md and the folder at commit 831ff0e. It shows what the files ask for, not the result of running them.
Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.
From allowed-tools in the SKILL.md frontmatter.
No scripts in the folder and no shell commands in SKILL.md (its code samples are swift).
From the folder's file list and the shell code blocks in SKILL.md.
No URLs in SKILL.md.
From URLs in SKILL.md, links to its own repository left out.
Names no API keys, tokens, secrets or passwords.
From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.
Hs2tests loads about 5.4k tokens when it runs. Until then it costs about 36 tokens; SKILL.md has 1,478 words of instructions outside code blocks.
Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.
The automated check found no risky patterns in SKILL.md.
Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.
The full file from cmsj/Hammerspoon2 at commit 831ff0e, republished under its MIT licence (© cmsj). 1,478 words, ~5,410 tokens.
.claude/skills/hs2tests/SKILL.md (or your agent's skills folder).All tests use Swift Testing (not XCTest). Every test file starts with:
import Testing
import JavaScriptCore // for JS-level module tests
@testable import Hammerspoon_2Add other framework imports only as needed (e.g. import AppKit, import CoreAudio).
| What you're testing | Directory | Filename pattern |
|---|---|---|
| A module's JS API | Hammerspoon 2Tests/IntegrationTests/ | HSFooIntegrationTests.swift |
| A manager/engine class | Hammerspoon 2Tests/ManagerTests/ | FooManagerTests.swift |
| Console/completion logic | Hammerspoon 2Tests/ConsoleTests/ | FooConsoleTests.swift |
| Mock implementations | Hammerspoon 2Tests/Mocks/ | MockFoo.swift |
Tests are structs, not classes. Each module's test file must have a top-level wrapper suite that encloses all of that module's inner suites:
@Suite("hs.foo tests")
struct HSFooTests {
@Suite("hs.foo API structure tests")
struct HSFooStructureTests {
private func makeHarness() -> JSTestHarness {
let harness = JSTestHarness()
harness.loadModule(HSFooModule.self, as: "foo")
return harness
}
@Test("someMethod is a function")
func testSomeMethodIsFunction() {
makeHarness().expectTrue("typeof hs.foo.someMethod === 'function'")
}
}
@Suite("hs.foo calculations")
struct HSFooCalculationTests {
// ...
}
}The top-level suite name is always "hs.foo tests" (matching the module name) and the struct is named HSFooTests. All inner suites (HSFooStructureTests, HSFooCalculationTests, etc.) are nested inside it.
The private makeHarness() factory avoids repeating setup in every test. Each test
should create its own harness (they share no state).
JSTestHarness spins up an isolated JSContext and loads modules into it,
exactly mimicking the real runtime but without a full app launch.
// Load one module
harness.loadModule(HSFooModule.self, as: "foo")
// → available in JS as hs.foo
// Load multiple modules (e.g. hs.ax needs hs.application)
harness.loadModule(HSAXModule.self, as: "ax")
harness.loadModule(HSApplicationModule.self, as: "application")The companion .js file (hs.foo.js) is loaded automatically if it exists in the
bundle, so JS enhancements are included in integration tests without extra setup.
Prefer bringing equality-style checks into Swift with a plain #expect(...) rather
than expectTrue/expectFalse. expectTrue("typeof x === 'number'") only ever
reports "expected true, got false" — Swift Testing's #expect macro captures both
sides of a == comparison and reports the actual value on failure, which is far
more useful for debugging. Use the evalString/evalBool/evalInt/evalDouble/
evalTypeOf helpers on JSTestHarness for this:
// Type checks — use evalTypeOf, not expectTrue("typeof ... === '...'")
#expect(harness.evalTypeOf("hs.foo.someMethod") == "function")
#expect(harness.evalTypeOf("hs.foo.name") == "string")
// Equality checks against a known value — use the typed eval helpers
#expect(harness.evalString("hs.foo.name") == "expected string")
#expect(harness.evalInt("hs.foo.count") == 42)
#expect(harness.evalDouble("hs.foo.ratio") == 0.5)
#expect(harness.evalBool("hs.foo.isReady") == true)
// Reserve expectTrue/expectFalse for genuine boolean checks with no natural
// "actual vs expected" pair — comparisons, predicates, compound conditions:
harness.expectTrue("Array.isArray(hs.foo.list())")
harness.expectTrue("hs.foo.count >= 0")
harness.expectFalse("hs.foo.isEmpty()")
// expectEqual still works and is fine for quick one-offs, but a direct #expect
// is preferred for the reasons above
harness.expectEqual("hs.foo.name", "expected string")
// Run JS without asserting on the result
harness.eval("hs.foo.doSomething()")
harness.eval("""
var x = hs.foo.create({ title: 'Test' });
x.start();
""")
// Get the raw JSValue for complex assertions
let result = harness.evalValue("hs.foo.compute()")
#expect(result?.isObject == true)
#expect(result?.isNull == true || result?.isUndefined == true)
// Get the plain Swift value
let val = harness.eval("hs.foo.compute()") as? Double
#expect(val != nil)Check that a call does NOT throw (the most common check after any non-trivial eval):
harness.eval("hs.foo.doSomething(complexInput)")
#expect(!harness.hasException)Check that a call DOES throw (for invalid input validation):
harness.eval("hs.foo.doSomething(badInput)")
harness.expectException()Every new module should have at minimum two suites in its test file, both nested
inside a top-level wrapper suite (see Test structure above). If the module produces
JS-exported child objects (factory/constructor methods that return HSFoo instances),
a memory leak test is also mandatory — see Memory leak tests below.
One test per public protocol member, verifying it exists with the right JS type. These tests never touch real OS state and run in any environment.
@Suite("hs.foo tests")
struct HSFooTests {
@Suite("hs.foo API structure tests")
struct HSFooStructureTests {
private func makeHarness() -> JSTestHarness { ... }
// Functions
@Test("doThing is a function")
func testDoThingIsFunction() {
makeHarness().expectTrue("typeof hs.foo.doThing === 'function'")
}
// Properties (numbers, booleans, strings, objects)
@Test("count is a number")
func testCountIsNumber() {
makeHarness().expectTrue("typeof hs.foo.count === 'number'")
}
@Test("isEnabled defaults to true")
func testIsEnabledDefault() {
makeHarness().expectTrue("hs.foo.isEnabled === true")
}
// Sub-objects
@Test("geocoder is an object")
func testGeocoderIsObject() {
makeHarness().expectTrue("typeof hs.foo.geocoder === 'object'")
}
// Watcher emitter (if the module uses the EventEmitter watcher pattern)
@Test("_watcherEmitter is initialized by hs.foo.js")
func testWatcherEmitterInitialized() {
makeHarness().expectTrue(
"hs.foo._watcherEmitter !== null && hs.foo._watcherEmitter !== undefined"
)
}
// Input validation (methods should fail gracefully, not throw)
@Test("doThing() with null input returns null without throwing")
func testDoThingNullInput() {
let harness = makeHarness()
harness.eval("var r = hs.foo.doThing(null)")
harness.expectTrue("r === null || r === undefined")
#expect(!harness.hasException)
}
}
// MARK: - Suite 2
@Suite("hs.foo calculations")
struct HSFooCalculationTests {
private func makeHarness() -> JSTestHarness { ... }
@Test("distance between London and Paris is ~341km")
func testDistance() {
let harness = makeHarness()
harness.eval("var d = hs.foo.distance(51.5074, -0.1278, 48.8566, 2.3522)")
harness.expectTrue("Math.abs(d - 341402) < 5000")
#expect(!harness.hasException)
}
@Test("returned object has expected type and properties")
func testReturnedObject() {
let harness = makeHarness()
harness.eval("var obj = hs.foo.create({ title: 'Test' })")
harness.expectTrue("typeof obj === 'object'")
harness.expectTrue("typeof obj.identifier === 'string'")
harness.expectTrue("obj.identifier.length > 0")
}
@Test("two created objects have different identifiers")
func testUniqueIdentifiers() {
let harness = makeHarness()
harness.expectTrue("""
(function() {
var a = hs.foo.create({ title: 'A' });
var b = hs.foo.create({ title: 'B' });
return a.identifier !== b.identifier;
})()
""")
}
}
}Test that methods return correct values for deterministic inputs — pure calculations, round-trips, invariants. No OS permissions or hardware required. (See example above.)
Tests that require real OS state (accessibility, microphone, audio hardware, etc.)
must be guarded with .disabled(if:) so they skip gracefully in environments
where the permission or hardware is absent. These also nest inside the top-level
HSFooTests wrapper.
private nonisolated func isAccessibilityEnabled() -> Bool {
AXIsProcessTrusted()
}
@Suite("hs.foo tests")
struct HSFooTests {
// ...
@Suite("hs.foo real-hardware tests",
.serialized,
.disabled(if: !isAccessibilityEnabled(), "Accessibility not granted"))
struct HSFooHardwareTests {
// Tests that call real OS APIs
}
}The guard function MUST be nonisolated so it can be called from the .disabled
trait expression, which is evaluated outside any actor.
Any module whose API lets JS create instances of a Swift class (HSTimer,
HSHotkey, HSBonjourSearch, etc.) must have at least one leak test.
These tests verify that after hs.reload() (simulated by shutdownForLeakTest())
every child object is properly freed — no strong-reference cycles, no stale
NSNotificationCenter observers, no selfRetain left set.
WeakLeakTracker — holds weak references to tracked objects without
preventing their deallocation. Call tracker.track(swiftObj) while the object is
alive, then tracker.assertNoLeaks() after everything is torn down.
harness.shutdownForLeakTest() — calls module.shutdown() on every loaded
module, removes hs from the JS global object, then runs
JSSynchronousGarbageCollectForDebugging so ObjC bridge finalizers execute
before the function returns.
// MARK: - Memory Leak Tests
@Test("Active HSFoo is released after shutdown")
func testFooDoesNotLeakAfterReload() {
let tracker = WeakLeakTracker()
autoreleasepool {
let harness = JSTestHarness()
harness.loadModule(HSFooModule.self, as: "foo")
// 1. CREATE the object
harness.eval("var obj = hs.foo.create(…)")
// 2. ACTIVELY USE IT — call start(), send(), enter(), findServices(), etc.
// Do not just create and immediately discard. The goal is to exercise
// the path where the object holds OS resources when shutdown is called.
harness.eval("obj.start()")
// 3. TRACK the underlying Swift object while it is still alive
if let swift = harness.evalValue("obj")?.toObjectOf(HSFoo.self) as? HSFoo {
tracker.track(swift)
}
// 4. Drop the JS reference, then shut down
harness.eval("obj = null")
harness.shutdownForLeakTest()
} // autorelease pool drained here; JSValue ObjC refs released, JSContext freed
tracker.assertNoLeaks()
}| Rule | Reason |
|---|---|
Wrap harness in an autoreleasepool {} block | Drains ObjC autorelease pool (including JSValues from eval()) so the JSContext is freed before the weak-ref check runs — a plain do {} block does not drain the pool and will silently miss leaks |
| Track the object before nulling the JS var | evalValue() returns nil after the var is cleared |
Extract the Swift object with toObjectOf(T.self) | All child types are @objc class NSObject subclasses; this cast is safe |
Call shutdownForLeakTest() inside the autoreleasepool {} block | Sync GC must run while the context is still alive |
Call assertNoLeaks() outside the autoreleasepool {} block | The context (and any bridged objects) must be released first |
| Start/use the object actively — see table below | An unstarted object skips the interesting cleanup paths (selfRetain, NSNotificationCenter observers, OS browser delegates, taskTracker strong refs, etc.) |
| Object | How to activate |
|---|---|
HSTimer (repeating) | hs.timer.doEvery(0.05, fn) + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.1)) so it fires |
HSTimer (one-shot) | hs.timer.doAfter(0.05, fn) + same run-loop drain |
HSHotkey | bind() already enables it; add hk.disable() + hk.enable() to cycle state |
HSHotkeyModal | modal.bind(…) to add hotkeys + modal.enter() to make it active |
HSEventTap | tap.start() — succeeds with Accessibility, fails silently without it; destroy() handles both |
HSSpotlightQuery | q.setQuery("…").setCallback(fn).start() — use an intentionally unmatchable predicate |
HSNotification | n.send() — display may fail without permission; the object is still used |
HSBonjourSearch | search.findServices('_http._tcp.', 'local.', fn) to start browsing |
HSTask | t.start() with a long-running command (/bin/sleep 10) so the process is definitely alive when shutdown() is called |
Timer(target:selector:) holds a strong ref to HSTimer
as its target until invalidate() is called. shutdown() → destroy() → stop()
→ invalidate() is the only release path.enabledHotkeys is a strong array in HSHotkeyModule. Enabled
hotkeys are not freed until shutdown() clears the array.start() sets selfRetain = self to keep the tap alive while
capturing events. destroy() → stop() clears it.NSNotificationCenter and
NSNetServiceBrowser hold strong delegate/observer refs. destroy() removes
them; without destroy() these objects can never be freed.registerActiveTask() stores the task in a strong taskTracker
inside the module. It is released only when the module itself is freed.You do not need separate tests for "created but not started" and "active". A single test that actively uses the object is more valuable than two tests where one skips the interesting cleanup paths.
var fired = false
harness.registerCallback("onEvent") {
fired = true
}
// In JS, call: __test_callback('onEvent')
harness.eval("hs.timer.doAfter(0.05, () => __test_callback('onEvent'))")
let success = harness.waitFor(timeout: 0.2) { fired }
#expect(success, "callback should have fired")
#expect(fired)For callbacks with typed arguments:
var exitCode: Int = -1
harness.registerCallback("onDone") { (code: Int) in
exitCode = code
}
// In JS: taskComplete(0) [the callback is registered as the global name]let success = harness.waitFor(timeout: 0.5) { someCondition }
#expect(success, "condition should have been met")waitFor spins the RunLoop in 10ms steps so timers and notifications fire normally.
@Test("task fires completion callback")
func testTaskCompletion() async {
let harness = JSTestHarness()
harness.loadModule(HSTaskModule.self, as: "task")
var done = false
harness.registerCallback("onDone") { done = true }
harness.eval("hs.task.new('/bin/echo', ['hi'], () => __test_callback('onDone')).start()")
let ok = await harness.waitForAsync(timeout: 2.0) { done }
#expect(ok)
}For test suites that touch async Swift machinery, add an async init that drains
the queue so one test's work doesn't bleed into the next:
@Suite("hs.task tests", .serialized)
struct HSTaskTests {
init() async {
await JSTestHarness.drainMainActorQueue()
}
}Keep test timers fast. Recommended values:
waitFor timeout: 3–5× the timer interval (enough headroom, short enough to fail fast)When a test must write to shared OS state (system pasteboard, files), save and restore it in a helper:
private func withSavedPasteboard(_ body: () -> Void) {
let saved = NSPasteboard.general.pasteboardItems?.map { ... }
body()
// restore...
}
@Test("writeString round-trips through readString")
func testStringRoundTrip() {
withSavedPasteboard {
let harness = makeHarness()
harness.eval("hs.pasteboard.writeString('hello')")
harness.expectEqual("hs.pasteboard.readString()", "hello")
}
}Mark suites that touch shared state with .serialized to prevent races:
@Suite("hs.pasteboard read/write tests", .serialized)
struct HSPasteboardReadWriteTests { ... }For testing internal Swift logic that has no JS surface (managers, pure value types, enum metadata, etc.), use Swift Testing directly without JSTestHarness:
import Testing
import Foundation
@testable import Hammerspoon_2
struct PermissionsTypeMetadataTests {
@Test("All permission types have non-empty displayName")
func testAllDisplayNamesNonEmpty() {
for permType in PermissionsType.allCases {
#expect(!permType.displayName.isEmpty)
}
}
@Test("accessibility displayName is correct")
func testAccessibilityDisplayName() {
#expect(PermissionsType.accessibility.displayName == "Accessibility")
}
}When a component under test has external dependencies (JS engine, file system,
settings), inject a mock via dependency injection and place the mock in
Hammerspoon 2Tests/Mocks/:
// Mocks/MockFoo.swift
import Foundation
@testable import Hammerspoon_2
class MockFoo: FooProtocol {
var callCount = 0
var shouldFail = false
var lastArgument: String?
func doThing(_ arg: String) throws {
callCount += 1
lastArgument = arg
if shouldFail { throw SomeError.failure }
}
func reset() {
callCount = 0
shouldFail = false
lastArgument = nil
}
}Expose every configurable behaviour as a Bool flag (shouldFail, shouldThrow)
and record all call arguments so tests can assert on them.
setMode(), change audio routing, set display
origin, or mirror displays — these disrupt the developer's desktop mid-run..then method; don't await actual network results.check* (which returns a bool) but not request* (which shows a system
dialog). Gate hardware-dependent tests with .disabled(if:).*Tests/ subdirectoryHSFooIntegrationTests.swift patternTesting, JavaScriptCore, @testable import Hammerspoon_2@Suite("hs.foo tests") struct HSFooTests {}@Test("description") on each functionmakeHarness() factory avoids repeated boilerplate.disabled(if: ...) with a nonisolated guard#expect(!harness.hasException) after every non-trivial eval()waitForAsync or waitFor rather than Thread.sleep where possible.serialized and save/restore shared statetestFooDoesNotLeakAfterReload() test exists that (a) creates AND actively starts the object, (b) tracks it with WeakLeakTracker, (c) wraps the harness in an autoreleasepool {} block, and (d) calls tracker.assertNoLeaks() after the block© cmsj, MIT. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file
Just SKILL.md in .claude/skills/HSTests of cmsj/Hammerspoon2.
Open the folder on GitHubat commit 831ff0e
Hs2tests next to the 2 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.
Guide for writing tests in Hammerspoon 2 — framework, structure, JSTestHarness patterns, async, environment guards, and what not to test. Hs2tests is an agent skill from cmsj/Hammerspoon2.
Run `npx skills add cmsj/Hammerspoon2 --skill hs2tests -a claude-code`. Or copy the skill folder (.claude/skills/HSTests in cmsj/Hammerspoon2) into .claude/skills/hs2tests in your project. Claude Code loads it when a task matches its description.
Run `npx skills add cmsj/Hammerspoon2 --skill hs2tests -a codex`. Or copy the skill folder (.claude/skills/HSTests in cmsj/Hammerspoon2) into .agents/skills/hs2tests in your project. Codex loads it when a task matches its description.
Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add cmsj/Hammerspoon2 --skill hs2tests -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/hs2tests, .gemini/skills/hs2tests, .github/skills/hs2tests and .opencode/skills/hs2tests in your project.
SKILL.md names no scripts, command-line tools or credentials: Hs2tests is instructions for the agent only.
SKILL.md contains no URLs. Any network use would come from the scripts or tools the agent runs. This is read from the text; nothing was executed.
Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.
Hs2tests is published under the MIT licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.
About 5.4k tokens (SKILL.md is roughly 22k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.
Skills that share tags, products or a category with Hs2tests: Hs2module (cmsj/Hammerspoon2, 113 stars) and Hs2type (cmsj/Hammerspoon2, 113 stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.
cmsj (a GitHub user) maintains it in cmsj/Hammerspoon2, which has 113 GitHub stars. The repository holds 3 skills in this directory. The repository was last updated on October 6, 2026.
Source: cmsj/Hammerspoon2 on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.