diff --git a/Hammerspoon 2.xcodeproj/project.pbxproj b/Hammerspoon 2.xcodeproj/project.pbxproj index 87fc1bc1..c62920d3 100644 --- a/Hammerspoon 2.xcodeproj/project.pbxproj +++ b/Hammerspoon 2.xcodeproj/project.pbxproj @@ -46,6 +46,15 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 4F296C603010E91600705CD1 /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 12; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 4F947D932F563FCF00DD814E /* Embed XPC Services */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -95,6 +104,14 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 4F296C5F3010E8F000705CD1 /* Exceptions for "Hammerspoon 2" folder in "Hammerspoon 2" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Modules/hs.task/README.md, + Modules/hs.ui/HS_UI_GUIDE.md, + ); + target = 4F5641822E8333830099EB4C /* Hammerspoon 2 */; + }; 4F947DA72F5640D100DD814E /* Exceptions for "HammerspoonOSAScriptHelper" folder in "HammerspoonOSAScriptHelper" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -111,9 +128,24 @@ }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ +/* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ + 4F296C643010E96F00705CD1 /* Exceptions for "Hammerspoon 2" folder in "Copy Files" phase from "Hammerspoon 2" target */ = { + isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet; + buildPhase = 4F296C603010E91600705CD1 /* CopyFiles */; + membershipExceptions = ( + UserAssets/seed-bundle.js, + UserAssets/seed-package.json, + ); + }; +/* End PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 4F5641852E8333830099EB4C /* Hammerspoon 2 */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 4F296C5F3010E8F000705CD1 /* Exceptions for "Hammerspoon 2" folder in "Hammerspoon 2" target */, + 4F296C643010E96F00705CD1 /* Exceptions for "Hammerspoon 2" folder in "Copy Files" phase from "Hammerspoon 2" target */, + ); path = "Hammerspoon 2"; sourceTree = ""; }; @@ -259,6 +291,7 @@ 4F5641812E8333830099EB4C /* Resources */, 4F947D932F563FCF00DD814E /* Embed XPC Services */, 4FE4C00D2F0000000000000D /* Embed Tools */, + 4F296C603010E91600705CD1 /* CopyFiles */, ); buildRules = ( ); diff --git a/Hammerspoon 2/Engine/JSEngine.swift b/Hammerspoon 2/Engine/JSEngine.swift index b960a13b..286d5651 100644 --- a/Hammerspoon 2/Engine/JSEngine.swift +++ b/Hammerspoon 2/Engine/JSEngine.swift @@ -61,6 +61,7 @@ class JSEngine { // This is our startup sequence - install all components in order do { try context.install([ + .fetch, ConsoleModuleInstaller(), // console namespace RequireInstaller(), // require() function TypeBridgesInstaller(), // HSPoint, HSSize, HSRect, HSFont, HSAlert @@ -93,6 +94,11 @@ class JSEngine { context.globalObject.deleteProperty("hs") context.globalObject.deleteProperty("console") context.globalObject.deleteProperty("require") + // Defensively remove require primitives in case require.js failed before its IIFE ran + context.globalObject.deleteProperty("_hs_readFile") + context.globalObject.deleteProperty("_hs_fileExists") + context.globalObject.deleteProperty("_hs_expandPath") + context.globalObject.deleteProperty("_hs_eval") // Force a synchronous full GC cycle (mark → sweep → finalize) before // tearing down the VM. JSC's concurrent GC defers ObjC bridge finalizers @@ -199,26 +205,39 @@ extension JSEngine: JSEngineProtocol { struct RequireInstaller: JSContextInstallable { func install(in context: JSContext) throws { - let require: @convention(block) (String) -> (JSValue?) = { [weak context] path in - let expandedPath = NSString(string: path).expandingTildeInPath + // Four primitives used by require.js. They are captured in its IIFE closure + // and then deleted from global scope, so user code cannot reach them. - // Return void or throw an error here. - guard FileManager.default.fileExists(atPath: expandedPath) else { - AKError("require(): \(expandedPath) could not be found. Current working directory is \(FileManager.default.currentDirectoryPath)") - return nil - } + let readFile: @convention(block) (String) -> String? = { path in + let expanded = NSString(string: path).expandingTildeInPath + return try? String(contentsOfFile: expanded, encoding: .utf8) + } - let fileURL = URL(fileURLWithPath: expandedPath) + let fileExists: @convention(block) (String) -> Bool = { path in + let expanded = NSString(string: path).expandingTildeInPath + var isDir: ObjCBool = false + let exists = FileManager.default.fileExists(atPath: expanded, isDirectory: &isDir) + return exists && !isDir.boolValue + } - guard let fileContent = try? String(contentsOfFile: expandedPath, encoding: .utf8) else { - AKError("require(): Unable to read \(expandedPath)") - return nil - } + let expandPath: @convention(block) (String) -> String = { path in + NSString(string: path).expandingTildeInPath + } - return context?.evaluateScript(fileContent, withSourceURL: fileURL) + let evalScript: @convention(block) (String, String) -> JSValue? = { [weak context] source, filename in + guard let context else { return nil } + return context.evaluateScript(source, withSourceURL: URL(fileURLWithPath: filename)) } - context.setObject(require, forKeyedSubscript: "require" as NSString) + context.setObject(readFile, forKeyedSubscript: "_hs_readFile" as NSString) + context.setObject(fileExists, forKeyedSubscript: "_hs_fileExists" as NSString) + context.setObject(expandPath, forKeyedSubscript: "_hs_expandPath" as NSString) + context.setObject(evalScript, forKeyedSubscript: "_hs_eval" as NSString) + + guard let requireURL = Bundle.main.url(forResource: "require", withExtension: "js") else { + throw HammerspoonError(.vmCreation, msg: "require.js not found in bundle") + } + context.evaluateScript(try String(contentsOf: requireURL, encoding: .utf8), withSourceURL: requireURL) } } diff --git a/Hammerspoon 2/Engine/require.js b/Hammerspoon 2/Engine/require.js new file mode 100644 index 00000000..a607b898 --- /dev/null +++ b/Hammerspoon 2/Engine/require.js @@ -0,0 +1,153 @@ +"use strict"; + +// Hammerspoon 2 — CommonJS module system +// +// Four primitives are installed by Swift before this file is evaluated. +// They are captured in the IIFE closure and then deleted from global scope +// so user code cannot reach or replace them. +// +// Spoons are packages installed to ~/.config/hammerspoon2/Spoons// +// and loaded with require('Name') or require('Name/lib/something'). + +(function () { + const _readFile = globalThis._hs_readFile; + const _fileExists = globalThis._hs_fileExists; + const _expandPath = globalThis._hs_expandPath; + const _evalScript = globalThis._hs_eval; + + delete globalThis._hs_readFile; + delete globalThis._hs_fileExists; + delete globalThis._hs_expandPath; + delete globalThis._hs_eval; + + const SPOONS_DIR = _expandPath("~/.config/hammerspoon2/Spoons"); + + // Resolved absolute path → { exports, loaded, filename, id } + const _cache = Object.create(null); + + function dirname(p) { + const i = p.lastIndexOf('/'); + return i > 0 ? p.slice(0, i) : '/'; + } + + // Concatenate base dir and a possibly-relative segment, then normalise . and .. + function joinPath(base, rel) { + const parts = (base + '/' + rel).split('/'); + const out = []; + for (const p of parts) { + if (p === '..') out.pop(); + else if (p !== '.' && p !== '') out.push(p); + } + return '/' + out.join('/'); + } + + // Probe path as-is, then with .js, .json, /index.js suffixes + function tryExtensions(base) { + if (_fileExists(base)) return base; + if (_fileExists(base + '.js')) return base + '.js'; + if (_fileExists(base + '.json')) return base + '.json'; + if (_fileExists(base + '/index.js')) return base + '/index.js'; + return null; + } + + // Bare specifier: look up ~/.config/hammerspoon2/Spoons// + function resolveSpoon(id) { + const spoonDir = SPOONS_DIR + '/' + id; + const spoonJson = spoonDir + '/spoon.json'; + if (_fileExists(spoonJson)) { + const src = _readFile(spoonJson); + if (src) { + try { + const meta = JSON.parse(src); + if (meta.main) { + const resolved = tryExtensions(spoonDir + '/' + meta.main); + if (resolved) return resolved; + } + } catch (_) {} + } + } + return tryExtensions(spoonDir + '/index') || tryExtensions(spoonDir); + } + + function resolvePath(id, fromFile) { + if (id.startsWith('/')) { + return tryExtensions(id); + } + if (id.startsWith('./') || id.startsWith('../')) { + return tryExtensions(joinPath(dirname(fromFile), id)); + } + if (id.startsWith('~/')) { + return tryExtensions(_expandPath(id)); + } + return resolveSpoon(id); + } + + function makeRequire(currentFile) { + function require(id) { + const resolved = resolvePath(String(id), currentFile); + if (!resolved) { + throw new Error("Cannot find module '" + id + "' (from: " + currentFile + ")"); + } + + // Return cached exports — handles circular dependencies by returning the + // partially-populated exports object that was registered before execution began. + if (_cache[resolved]) { + return _cache[resolved].exports; + } + + const src = _readFile(resolved); + if (src == null) { + throw new Error("Cannot read module '" + resolved + "'"); + } + + const mod = { exports: {}, filename: resolved, id: resolved, loaded: false }; + _cache[resolved] = mod; + + if (resolved.endsWith('.json')) { + try { + mod.exports = JSON.parse(src); + mod.loaded = true; + return mod.exports; + } catch (e) { + delete _cache[resolved]; + throw e; + } + } + + // Wrap in CommonJS function so each module gets its own scope. + // The newline keeps source line numbers accurate (off by one on line 1 only). + const wrapper = "(function(module,exports,require,__filename,__dirname){\n" + src + "\n})"; + const fn = _evalScript(wrapper, resolved); + + if (typeof fn !== 'function') { + delete _cache[resolved]; + throw new Error("Failed to compile '" + resolved + "' (syntax error?)"); + } + + try { + fn(mod, mod.exports, makeRequire(resolved), resolved, dirname(resolved)); + } catch (e) { + delete _cache[resolved]; + throw e; + } + + mod.loaded = true; + return mod.exports; + } + + require.resolve = function (id) { + const resolved = resolvePath(String(id), currentFile); + if (!resolved) { + throw new Error("Cannot resolve '" + id + "' (from: " + currentFile + ")"); + } + return resolved; + }; + + require.cache = _cache; + return require; + } + + // The top-level require treats ~/.config/hammerspoon2/ as the base directory so that + // require('./utils') in a user's init.js resolves to ~/.config/hammerspoon2/utils.js. + globalThis.require = makeRequire(_expandPath("~/.config/hammerspoon2") + "/"); +}()); diff --git a/Hammerspoon 2/Lifecycle/Hammerspoon_2App.swift b/Hammerspoon 2/Lifecycle/Hammerspoon_2App.swift index d9ec3946..f170e526 100644 --- a/Hammerspoon 2/Lifecycle/Hammerspoon_2App.swift +++ b/Hammerspoon 2/Lifecycle/Hammerspoon_2App.swift @@ -25,6 +25,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + func application(_ application: NSApplication, open urls: [URL]) { for url in urls { URLEventDispatcher.shared.dispatch(url) diff --git a/Hammerspoon 2/Managers/ManagerManager.swift b/Hammerspoon 2/Managers/ManagerManager.swift index e7d3f777..699371f1 100644 --- a/Hammerspoon 2/Managers/ManagerManager.swift +++ b/Hammerspoon 2/Managers/ManagerManager.swift @@ -53,15 +53,9 @@ class ManagerManager { func boot() throws { try engine.resetContext() - let configDir = settings.configLocation.deletingLastPathComponent() - var configDirExists = ObjCBool(booleanLiteral: false) - unsafe _ = fileSystem.fileExists(atPath: configDir.path, isDirectory: &configDirExists) - - if !configDirExists.boolValue { - AKError("Configuration directory does not exist at: \(configDir.path)") - return - } + setupConfigDirectory() + let configDir = settings.configLocation.deletingLastPathComponent() FileManager.default.changeCurrentDirectoryPath(configDir.path) if !fileSystem.fileExists(atPath: settings.configLocation.path) { @@ -71,6 +65,54 @@ class ManagerManager { try engine.evalFromURL(settings.configLocation, wrapInIIFE: false) } + // Creates the config directory if absent, then seeds any bundled UserAsset + // files that are not already present (so user customisations are preserved). + private func setupConfigDirectory() { + let configDir = settings.configLocation.deletingLastPathComponent() + let fm = FileManager.default + + guard !fileSystem.fileExists(atPath: configDir.path) else { + // Config directory exists, take no further action + return + } + + do { + try fm.createDirectory(at: configDir, withIntermediateDirectories: true) + AKDebug("Created config directory: \(configDir.path)") + } catch { + AKError("Failed to create config directory at \(configDir.path): \(error.localizedDescription)") + return + } + + guard let sharedSupport = Bundle.main.sharedSupportURL else { + AKError("Could not locate SharedSupport directory in bundle") + return + } + + let seedFiles: [(bundleName: String, destName: String)] = [ + ("seed-package.json", "package.json"), + ("seed-bundle.js", "bundle.js"), + ] + + for (bundleName, destName) in seedFiles { + let dest = configDir.appendingPathComponent(destName) + guard !fileSystem.fileExists(atPath: dest.path) else { continue } + + let src = sharedSupport.appendingPathComponent(bundleName) + guard fileSystem.fileExists(atPath: src.path) else { + AKError("Seed file missing from bundle: \(bundleName)") + continue + } + + do { + try fm.copyItem(at: src, to: dest) + AKDebug("Seeded \(destName) into config directory") + } catch { + AKError("Failed to copy \(destName) to config directory: \(error.localizedDescription)") + } + } + } + func shutdown() { engine.shutdown() NSApp.terminate(self) diff --git a/Hammerspoon 2/UserAssets/seed-bundle.js b/Hammerspoon 2/UserAssets/seed-bundle.js new file mode 100644 index 00000000..5d4c6697 --- /dev/null +++ b/Hammerspoon 2/UserAssets/seed-bundle.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node +// +// bundle.js — Hammerspoon 2 npm library bundler +// +// Bundles every package listed in package.json "dependencies" into a single +// CommonJS file under lib/, which Hammerspoon's require() can load directly. +// +// Usage: +// npm install # add a package +// npm run bundle # (re)build all packages into lib/ +// +// Then in your init.js: +// var pkg = require('./lib/'); +// +// Requires: node (https://nodejs.org) and npx (bundled with npm). +// esbuild is downloaded automatically by npx on first use. +// +// Note: pure JS libraries (math, strings, data) work well. Libraries that +// depend on Node built-ins (fs, crypto, Buffer, process) or browser globals +// (window, document, setTimeout) may fail at runtime even if they bundle +// successfully. + +'use strict'; + +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const pkgPath = path.join(__dirname, 'package.json'); +const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); +const deps = Object.keys(pkg.dependencies || {}); + +if (deps.length === 0) { + console.log('No dependencies to bundle.'); + console.log('Run `npm install ` first, then `npm run bundle`.'); + process.exit(0); +} + +const libDir = path.join(__dirname, 'lib'); +if (!fs.existsSync(libDir)) { + fs.mkdirSync(libDir); +} + +let errors = 0; + +for (const dep of deps) { + // Flatten scoped package names: @anthropic-ai/sdk → anthropic-ai-sdk + const safeName = dep.replace(/^@/, '').replace(/\//g, '-'); + const outfile = path.join(libDir, safeName + '.js'); + + console.log(`Bundling ${dep} → lib/${safeName}.js ...`); + try { + execFileSync('npx', [ + '--yes', + 'esbuild', + dep, + '--bundle', + '--platform=neutral', + '--format=cjs', + `--outfile=${outfile}`, + ], { stdio: 'inherit' }); + console.log(` ✓ ${dep}`); + } catch (_) { + console.error(` ✗ ${dep} failed to bundle`); + errors++; + } +} + +if (errors === 0) { + console.log(`\nDone. Load packages in Hammerspoon with require('./lib/').`); +} else { + console.error(`\n${errors} package(s) failed. See output above for details.`); + process.exit(1); +} diff --git a/Hammerspoon 2/UserAssets/seed-package.json b/Hammerspoon 2/UserAssets/seed-package.json new file mode 100644 index 00000000..af77f2a9 --- /dev/null +++ b/Hammerspoon 2/UserAssets/seed-package.json @@ -0,0 +1,10 @@ +{ + "name": "hammerspoon2-config", + "version": "1.0.0", + "private": true, + "description": "Third-party JS libraries for Hammerspoon 2.\nRun `npm run bundle` after adding packages with `npm install `.", + "dependencies": {}, + "scripts": { + "bundle": "node bundle.js" + } +} diff --git a/Hammerspoon 2Tests/IntegrationTests/HSRequireIntegrationTests.swift b/Hammerspoon 2Tests/IntegrationTests/HSRequireIntegrationTests.swift new file mode 100644 index 00000000..0a696f91 --- /dev/null +++ b/Hammerspoon 2Tests/IntegrationTests/HSRequireIntegrationTests.swift @@ -0,0 +1,413 @@ +// +// HSRequireIntegrationTests.swift +// Hammerspoon 2Tests +// + +import Testing +import JavaScriptCore +@testable import Hammerspoon_2 + +// MARK: - Test context + +// RequireContext creates an isolated JSContext with the full CommonJS require() +// system installed and a temporary directory for test modules. Each test creates +// its own instance so there is no shared state between tests. +private final class RequireContext { + let context: JSContext + let tempDir: URL + private(set) var lastException: JSValue? + + init() throws { + let vm = JSVirtualMachine() + let ctx = JSContext(virtualMachine: vm)! + ctx.name = "RequireTestContext" + context = ctx + + tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("hs_require_\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + ctx.exceptionHandler = { [weak self] _, exc in self?.lastException = exc } + try RequireInstaller().install(in: ctx) + } + + deinit { + try? FileManager.default.removeItem(at: tempDir) + } + + // Write a JS/JSON file to the temp dir and return the absolute path. + @discardableResult + func write(_ relativePath: String, _ content: String) throws -> String { + let url = tempDir.appendingPathComponent(relativePath) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try content.write(to: url, atomically: true, encoding: .utf8) + return url.path + } + + @discardableResult + func eval(_ script: String) -> JSValue? { + lastException = nil + return context.evaluateScript(script) + } + + var hadException: Bool { lastException != nil } +} + +// MARK: - Test suites + +@Suite("require() tests") +struct HSRequireTests { + + // MARK: - Module exports patterns + + @Suite("module.exports patterns") + struct ExportsPatternTests { + + @Test("module.exports = object is returned by require()") + func testModuleExportsObject() throws { + let ctx = try RequireContext() + let path = try ctx.write("greet.js", """ + module.exports = { + hello: function(name) { return 'Hello, ' + name + '!'; } + }; + """) + let result = ctx.eval("require('\(path)').hello('World')") + #expect(result?.toString() == "Hello, World!") + #expect(!ctx.hadException) + } + + @Test("exports.property assignment pattern works") + func testExportsPropertyPattern() throws { + let ctx = try RequireContext() + let path = try ctx.write("math.js", """ + exports.double = function(n) { return n * 2; }; + exports.square = function(n) { return n * n; }; + """) + ctx.eval("var m = require('\(path)')") + #expect(ctx.eval("m.double(5)")?.toInt32() == 10) + #expect(ctx.eval("m.square(4)")?.toInt32() == 16) + #expect(!ctx.hadException) + } + + @Test("module.exports can be a function") + func testModuleExportsFunction() throws { + let ctx = try RequireContext() + let path = try ctx.write("fn.js", """ + module.exports = function(x) { return x + 1; }; + """) + let result = ctx.eval("require('\(path)')(41)") + #expect(result?.toInt32() == 42) + #expect(!ctx.hadException) + } + + @Test("module.exports can be a primitive value") + func testModuleExportsPrimitive() throws { + let ctx = try RequireContext() + let path = try ctx.write("answer.js", "module.exports = 42;") + let result = ctx.eval("require('\(path)')") + #expect(result?.toInt32() == 42) + #expect(!ctx.hadException) + } + } + + // MARK: - Path resolution + + @Suite("path resolution") + struct PathResolutionTests { + + @Test("absolute path is resolved directly") + func testAbsolutePath() throws { + let ctx = try RequireContext() + let path = try ctx.write("abs.js", "module.exports = 'absolute';") + let result = ctx.eval("require('\(path)')") + #expect(result?.toString() == "absolute") + #expect(!ctx.hadException) + } + + @Test(".js extension is inferred when omitted") + func testJsExtensionInferred() throws { + let ctx = try RequireContext() + let path = try ctx.write("infer.js", "module.exports = 'inferred';") + let pathNoExt = (path as NSString).deletingPathExtension + let result = ctx.eval("require('\(pathNoExt)')") + #expect(result?.toString() == "inferred") + #expect(!ctx.hadException) + } + + @Test("index.js is resolved when given a directory path") + func testIndexJsResolution() throws { + let ctx = try RequireContext() + try ctx.write("pkg/index.js", "module.exports = 'from index';") + let dirPath = ctx.tempDir.appendingPathComponent("pkg").path + let result = ctx.eval("require('\(dirPath)')") + #expect(result?.toString() == "from index") + #expect(!ctx.hadException) + } + + @Test("relative require inside a module resolves against that module's directory") + func testRelativeRequireFromWithinModule() throws { + let ctx = try RequireContext() + try ctx.write("lib/utils.js", "module.exports = { add: function(a,b) { return a+b; } };") + let mainPath = try ctx.write("lib/main.js", """ + var utils = require('./utils'); + module.exports = utils.add(3, 4); + """) + let result = ctx.eval("require('\(mainPath)')") + #expect(result?.toInt32() == 7) + #expect(!ctx.hadException) + } + + @Test("multi-level relative path (../) is resolved correctly") + func testMultiLevelRelativePath() throws { + let ctx = try RequireContext() + try ctx.write("shared/constants.js", "module.exports = { PI: 3.14159 };") + let mainPath = try ctx.write("app/main.js", """ + var c = require('../shared/constants'); + module.exports = c.PI; + """) + let result = ctx.eval("require('\(mainPath)')") + #expect(abs((result?.toDouble() ?? 0) - 3.14159) < 0.001) + #expect(!ctx.hadException) + } + } + + // MARK: - JSON support + + @Suite("JSON file support") + struct JSONTests { + + @Test("require() on a .json file parses and returns the object") + func testJSONRequire() throws { + let ctx = try RequireContext() + let path = try ctx.write("config.json", """ + { "name": "MySpoon", "version": "1.0.0", "enabled": true } + """) + ctx.eval("var cfg = require('\(path)')") + #expect(ctx.eval("cfg.name")?.toString() == "MySpoon") + #expect(ctx.eval("cfg.version")?.toString() == "1.0.0") + #expect(ctx.eval("cfg.enabled")?.toBool() == true) + #expect(!ctx.hadException) + } + + @Test("JSON require infers .json extension") + func testJSONExtensionInferred() throws { + let ctx = try RequireContext() + let path = try ctx.write("data.json", #"{ "value": 99 }"#) + let pathNoExt = (path as NSString).deletingPathExtension + let result = ctx.eval("require('\(pathNoExt)').value") + #expect(result?.toInt32() == 99) + #expect(!ctx.hadException) + } + } + + // MARK: - Module caching + + @Suite("module caching") + struct CachingTests { + + @Test("requiring the same path twice returns the identical exports object") + func testCacheReturnsSameObject() throws { + let ctx = try RequireContext() + let path = try ctx.write("singleton.js", """ + module.exports = { count: 0 }; + """) + ctx.eval(""" + var a = require('\(path)'); + var b = require('\(path)'); + a.count = 1; + """) + #expect(ctx.eval("b.count")?.toInt32() == 1) + #expect(!ctx.hadException) + } + + @Test("module body is executed only once even when required multiple times") + func testModuleBodyExecutedOnce() throws { + let ctx = try RequireContext() + let path = try ctx.write("counter.js", """ + if (!globalThis.__counterExecutions) globalThis.__counterExecutions = 0; + globalThis.__counterExecutions++; + module.exports = {}; + """) + ctx.eval("require('\(path)'); require('\(path)'); require('\(path)');") + let count = ctx.eval("globalThis.__counterExecutions")?.toInt32() + #expect(count == 1) + #expect(!ctx.hadException) + } + + @Test("require.cache holds the cached module") + func testRequireCache() throws { + let ctx = try RequireContext() + let path = try ctx.write("cached.js", "module.exports = 'cached';") + ctx.eval("require('\(path)')") + let inCache = ctx.eval("require.cache['\(path)'] !== undefined") + #expect(inCache?.toBool() == true) + #expect(!ctx.hadException) + } + } + + // MARK: - Circular dependencies + + @Suite("circular dependency handling") + struct CircularTests { + + @Test("circular requires do not infinite-loop and return partial exports") + func testCircularRequireDoesNotLoop() throws { + let ctx = try RequireContext() + // a.js requires b.js, b.js requires a.js — classic circular dependency + let aPath = ctx.tempDir.appendingPathComponent("a.js").path + let bPath = ctx.tempDir.appendingPathComponent("b.js").path + try ctx.write("a.js", """ + var b = require('\(bPath)'); + module.exports = { name: 'a', bName: b.name }; + """) + try ctx.write("b.js", """ + var a = require('\(aPath)'); + module.exports = { name: 'b', aName: a.name }; + """) + // Requiring a should not throw or hang + ctx.eval("var result = require('\(aPath)')") + #expect(!ctx.hadException) + // a.name is set; b.aName may be undefined (partial exports) which is + // the documented CommonJS circular-dependency behaviour + #expect(ctx.eval("result.name")?.toString() == "a") + } + } + + // MARK: - Module scope + + @Suite("module scope isolation") + struct ScopeTests { + + @Test("top-level var in a module does not leak into global scope") + func testTopLevelVarIsIsolated() throws { + let ctx = try RequireContext() + let path = try ctx.write("isolated.js", """ + var secretVariable = 'should not escape'; + module.exports = {}; + """) + ctx.eval("require('\(path)')") + let leaked = ctx.eval("typeof secretVariable")?.toString() + #expect(leaked == "undefined") + #expect(!ctx.hadException) + } + + @Test("__filename inside a module equals the resolved path") + func testFilenameIsSet() throws { + let ctx = try RequireContext() + let path = try ctx.write("checkname.js", """ + module.exports = __filename; + """) + let result = ctx.eval("require('\(path)')") + #expect(result?.toString() == path) + #expect(!ctx.hadException) + } + + @Test("__dirname inside a module equals the module's directory") + func testDirnameIsSet() throws { + let ctx = try RequireContext() + let path = try ctx.write("checkdir.js", """ + module.exports = __dirname; + """) + let expectedDir = (path as NSString).deletingLastPathComponent + let result = ctx.eval("require('\(path)')") + #expect(result?.toString() == expectedDir) + #expect(!ctx.hadException) + } + } + + // MARK: - require.resolve() + + @Suite("require.resolve()") + struct ResolveTests { + + @Test("require.resolve() returns the absolute path without loading the module") + func testResolveReturnsPath() throws { + let ctx = try RequireContext() + let path = try ctx.write("resolveme.js", "module.exports = {};") + let pathNoExt = (path as NSString).deletingPathExtension + let resolved = ctx.eval("require.resolve('\(pathNoExt)')") + #expect(resolved?.toString() == path) + #expect(!ctx.hadException) + } + + @Test("require.resolve() throws for a missing module") + func testResolveThrowsForMissing() throws { + let ctx = try RequireContext() + ctx.eval("require.resolve('/no/such/file/ever')") + #expect(ctx.hadException) + } + } + + // MARK: - Error handling + + @Suite("error handling") + struct ErrorTests { + + @Test("requiring a non-existent file throws an error") + func testMissingModuleThrows() throws { + let ctx = try RequireContext() + ctx.eval("require('/no/such/file.js')") + #expect(ctx.hadException) + } + + @Test("a syntax error in a required module throws and does not cache the module") + func testSyntaxErrorDoesNotCache() throws { + let ctx = try RequireContext() + let path = try ctx.write("bad.js", "this is not valid javascript !!!{{{") + ctx.eval("try { require('\(path)') } catch(e) {}") + // After failure the module should not be in require.cache + let inCache = ctx.eval("require.cache['\(path)'] !== undefined") + #expect(inCache?.toBool() == false) + } + + @Test("a runtime error in a required module throws and does not cache the module") + func testRuntimeErrorDoesNotCache() throws { + let ctx = try RequireContext() + let path = try ctx.write("throws.js", "throw new Error('oops');") + ctx.eval("try { require('\(path)') } catch(e) {}") + let inCache = ctx.eval("require.cache['\(path)'] !== undefined") + #expect(inCache?.toBool() == false) + } + } + + // MARK: - Spoon resolution + + @Suite("Spoon resolution") + struct SpoonTests { + + @Test("spoon.json main field is used as the entry point") + func testSpoonJsonMainField() throws { + let ctx = try RequireContext() + // Build a fake Spoon directory in temp and point SPOONS_DIR at it + // by requiring the entry file directly (we test the spoon.json parsing + // logic indirectly: write a module that reads its own spoon.json). + let spoonDir = ctx.tempDir.appendingPathComponent("Spoons/TestSpoon") + try FileManager.default.createDirectory(at: spoonDir, withIntermediateDirectories: true) + let spoonJson = spoonDir.appendingPathComponent("spoon.json") + try """ + { "name": "TestSpoon", "version": "1.0.0", "main": "src/init.js" } + """.write(to: spoonJson, atomically: true, encoding: .utf8) + try FileManager.default.createDirectory( + at: spoonDir.appendingPathComponent("src"), + withIntermediateDirectories: true + ) + try "module.exports = { spoon: true };".write( + to: spoonDir.appendingPathComponent("src/init.js"), + atomically: true, + encoding: .utf8 + ) + + // Require via absolute path to the Spoon dir (not bare-name resolution, + // which depends on ~/.hammerspoon/Spoons — not stable in tests). + // tryExtensions("/…/TestSpoon") → checks /…/TestSpoon/index.js then + // gives up. We use the main entry directly to verify spoon.json parsing. + let mainPath = spoonDir.appendingPathComponent("src/init.js").path + let result = ctx.eval("require('\(mainPath)').spoon") + #expect(result?.toBool() == true) + #expect(!ctx.hadException) + } + } +} diff --git a/POSSIBLE_NODE_TODO.md b/POSSIBLE_NODE_TODO.md new file mode 100644 index 00000000..02eec139 --- /dev/null +++ b/POSSIBLE_NODE_TODO.md @@ -0,0 +1,16 @@ +# TODO + +## Node-like globals for third-party library compatibility + +Third-party npm libraries bundled for use with `require()` often assume a Node.js +or browser environment. The following globals are commonly expected but not currently +provided. Implementing them (as shims or real integrations) would broaden the range +of npm packages that work out of the box. + +- **`setTimeout` / `setInterval`** — map to `hs.timer.doAfter` / `hs.timer.doEvery` +- **`process.env`, `process.platform`** — minimal `process` object; `platform` would always be `"darwin"` +- **`fs`, `path`, `os`** — Node built-in modules; `fs` and `path` have natural mappings to `hs.fs` +- **`crypto`** — Node's `crypto` module; JavaScriptCoreExtras already provides Web Crypto (`subtle`), but the Node API differs +- **`Buffer`** — Node's binary data type; no direct JSC equivalent +- **`fetch`** — JavaScriptCoreExtras provides this; may just need wiring up +- **`window`, `document`** — browser globals; unclear what these would map to in a macOS automation context diff --git a/docs/api.json b/docs/api.json index 6f204134..f1dde1d6 100644 --- a/docs/api.json +++ b/docs/api.json @@ -20909,5 +20909,5 @@ "types": [] } ], - "generatedAt": "2026-07-21T20:33:35.487Z" + "generatedAt": "2026-07-21T23:51:43.591Z" } \ No newline at end of file