7 Commits
Author SHA1 Message Date
nokeo08 819cc5a5fb Set 1.3.0 release date 2026-08-14 13:05:33 -05:00
nokeo08 ec33648e74 Remove stepCount/stepSize; patterns own their geometry
The stepCount and stepSize knobs were two controls for one quantity users
actually care about (reach), and the number of steps is an implementation
detail nobody meaningfully tunes. Each pattern has a natural size and
resolution — a jitter is inherently small, an arc a broad curve — so those
now live as constants in each strategy rather than as global config.

- strategies.ts: each pattern defines its own step count and size; MoveContext
  drops `config` down to pure geometry (start/width/height/rng), and the
  module no longer imports Config at all (dissolving the type-only-import
  cycle workaround). line stays byte-for-byte: 250 one-pixel steps.
- executor.ts: executePath takes `config` for pacing (stepDelay); the path
  itself needs nothing from it.
- config.ts / cli.ts / move.ts / config.default.json: drop stepCount and
  stepSize from the type, seed, validation, resolver, CLI flags (-n, -s),
  and help. stepDelay stays as the one pacing lever.
- configFile.ts: tolerate the removed keys instead of rejecting them — every
  pre-1.3.0 install seeded stepCount, so a hard "unknown key" failure on
  upgrade is avoided. They're ignored with a one-line stderr notice; genuine
  unknown keys still error.

The -n/--step-count CLI flag (shipped since 1.0.0) is now an unknown option;
config files degrade gracefully, command lines don't. Stays in the unpushed
1.3.0 release. 64 tests pass.
2026-08-14 12:56:22 -05:00
nokeo08 db3310c247 Add pluggable movement strategies (v1.3.0)
Turn the hardcoded straight-line sweep into a strategy system behind three
seams so new patterns are easy to add and, for the first time, testable
without nut.js or a real screen:

- src/device.ts:     injectable Device seam over nut.js (autoDelayMs lives
                     here now); the only module that touches the native lib.
- src/strategies.ts: pure per-pattern path generators + registry + lenient
                     name resolution. Ships line, diagonal, jitter, walk,
                     arc, figureEight.
- src/executor.ts:   single executePath driver owning bounds policy
                     (abort/clamp/reflect), pacing, interrupt detection, and
                     restore-on-clean.

keeper.ts's simulateActivity now selects a strategy and delegates to the
executor; the default `line` pattern is byte-for-byte the previous behavior.

New config surface, layered CLI > file > default with strict validation:
- -p/--pattern <name>   movement strategy (names matched case/-/_-insensitive)
- -s/--step-size <px>   pixels per step; stepCount is now a step *count*

Robustness for the new edge-seeking patterns: interrupt detection compares
against the last commanded (rounded) point with a 2px tolerance, and
clamp/reflect stay a couple pixels off the screen edge, so sub-pixel cursor
placement on scaled/multi-monitor displays isn't misread as user activity.
jitter's radius scales with sweep length so it moves at the default stepSize.

Tests: new suites for strategies, the executor (all bounds policies,
rounding, interrupt, tolerance, pacing), and the keeper loop; config and
configFile suites extended for pattern/stepSize. editor.test.ts moved to
tests/ for consistency. 64 pass.
2026-08-13 15:36:22 -05:00
nokeo08 7777b16540 Add CHANGELOG.md 2026-06-29 12:27:16 -05:00
nokeo08 cff1c482a3 v1.2.0
Notable changes since v1.1.1:
- New -e/--edit flag opens the active config file in $EDITOR.
- Lazy import of keeper.ts so --help and --version skip the nut.js
  native load on cold start.
- Various audit cleanups: failRuntime() mirror, named Logger interface,
  errors.ts module, autoDelayMs moved out of module-load side effects,
  defaultConfigPath guards against unset HOME, noUncheckedIndexedAccess
  enabled in tsconfig.
- Added Bun test suite (34 tests across resolveConfig, loadConfigFile,
  editConfig).
- scripts/dev-setup.sh made POSIX-portable.
2026-06-17 22:20:59 -05:00
nokeo08 10dcc1791a Add -e/--edit flag: open config file in $EDITOR
New module src/editor.ts handles the flag end-to-end:

  - editorCommand(editor, path) returns the sh -c argv that lets the
    shell tokenize multi-word $EDITOR values like 'code --wait'.
    Extracted so editor.test.ts can verify construction without
    actually launching an editor.
  - editConfig(path) checks $EDITOR is set, checks the target file
    exists, spawns 'sh -c <editor> "$@" -- <path>' with stdio
    inherited, and exits with the editor's status code.

Refuses (CliError -> exit 2) when:
  - $EDITOR is unset or empty.
  - The target config file doesn't exist. (Same recovery hint as
    elsewhere: 'run move once or reinstall'.)

src/cli.ts adds the flag to the parser and printHelp(). src/move.ts
dispatches it after --version and before config-load. Resolution
mirrors the loader: --config <path> wins, else defaultConfigPath().

8 new tests in src/editor.test.ts cover the pure helper and both
refusal paths; the spawn success path is verified via manual
'EDITOR=true move -e' (would otherwise kill the test process).

README Usage block, Configuration section (new Editing subsection),
and Files table all updated to match.
2026-06-17 22:20:27 -05:00
nokeo08 cc7a487aca Lazy-import keeper.ts so --help and --version skip nut.js load
keeper.ts statically imports @nut-tree-fork/nut-js, which dlopens a
sizeable native .node binary. That load dominated cold-start: ~1.2s
for 'move --version' immediately after install, vs ~125ms warm.
For a flag that just prints a string, that's all overhead.

Change: dynamic 'await import("./keeper.ts")' placed after the
--help and --version short-circuits. The dynamic import is wrapped
in try/catch so import-time failures (e.g., missing native binary,
unsupported architecture) route through the same failRuntime() path
as anything thrown by the loop.

Expected cold start for --help and --version drops to ~50-150ms
(Bun + reading a handful of small files, no native module load).
move with no flags pays the same load cost as today.

tsc --noEmit and the test suite remain clean.
2026-06-17 21:57:42 -05:00
19 changed files with 1680 additions and 183 deletions
+121
View File
@@ -0,0 +1,121 @@
# Changelog
All notable changes to `move` are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.3.0] - 2026-08-14
### Added
- Pluggable movement strategies. New `-p, --pattern <name>` flag and
`pattern` config key select how the cursor moves: `line` (default,
unchanged behavior), `diagonal`, `jitter`, `walk`, `arc`, `figureEight`.
Each pattern owns its own size and step count as constants; there is no
user knob for sweep magnitude.
- Pattern names are matched leniently: case and separators are ignored, so
`figureEight`, `figure-eight`, `figure_eight`, and `FIGUREEIGHT` are all
accepted (on the CLI and in the config file) and resolve to the canonical
name.
- `src/device.ts`: injectable `Device` seam over nut.js, enabling unit
tests for movement without the native binary or a real screen.
- `src/strategies.ts`: pure, per-pattern path generators plus the registry
and name validation.
- `src/executor.ts`: single `executePath` driver owning bounds policy
(`abort`/`clamp`/`reflect`), pacing, interrupt detection, and restore.
- Test suites for strategies, the executor (all bounds policies, rounding,
interrupt), and the keeper loop.
### Changed
- `simulateActivity` no longer hardcodes a straight-line sweep; it selects a
strategy from the registry and delegates execution to `executePath`. The
default `line` pattern is byte-for-byte the previous behavior.
- Interrupt detection now compares against the last *commanded* (rounded)
point rather than an ideal target, so fractional/curved paths don't
self-trip.
- `mouse.config.autoDelayMs = 0` moved from `runKeeper` into
`createNutDevice` — the single place nut.js is wired up.
- `runKeeper(config, device?)` accepts an injected device for testing.
- Interrupt detection tolerates a small (2px) gap between the commanded and
read-back cursor position, and the `clamp`/`reflect` patterns stay a few
pixels off the screen edge. Together these avoid false "user activity"
aborts from sub-pixel cursor placement on scaled or multi-monitor setups,
which the new edge-seeking patterns would otherwise hit. `line` (policy
`abort`) is unaffected.
### Removed
- `-n, --step-count` flag and the `stepCount` / `stepSize` config keys. Sweep
size and step count are now intrinsic to each movement pattern, not user
knobs. Config files that still contain these keys keep working: the loader
ignores them with a one-line notice instead of rejecting them, so existing
installs (all seeded with `stepCount`) don't break on upgrade. The removed
CLI flag, however, is a hard error like any other unknown option.
## [1.2.0] - 2026-06-17
### Added
- `-e, --edit` flag opens the resolved config file in `$EDITOR`.
### Changed
- `keeper.ts` (and `@nut-tree-fork/nut-js`) is lazy-imported, so `--help`
and `--version` skip the nut.js load and start ~10x faster.
## [1.1.1] - 2026-06-17
### Changed
- Tests moved from `src/` to a top-level `tests/` directory.
- `tsconfig.json` sets `"types": ["bun"]` so VS Code resolves `bun:test`.
### Fixed
- `package.json` version now matches the published tag.
## [1.1.0] - 2026-06-17
### Added
- JSON config file support at `${XDG_CONFIG_HOME:-~/.config}/move/config.json`.
Precedence: CLI flags > config file > defaults. Strict validation.
- `-C, --config <path>` to override the default config path.
- Installer seeds `config.json` with project defaults on fresh install only.
- Bun test suite for `resolveConfig` and `loadConfigFile`.
### Changed
- Installer scripts now live in `scripts/`.
- `verbose` is now a first-class `Config` field; `resolveVerbose` removed.
- `CliError` extracted into `src/errors.ts`.
- `defaultConfigPath()` throws when both `$XDG_CONFIG_HOME` and `$HOME` are unset.
- `mouse.config.autoDelayMs = 0` moved into `runKeeper` (no module-load side effect).
- Runtime errors go through a `failRuntime` helper that mirrors `failUser`.
- `keeper.ts` declares a named `Logger` interface.
- `dev-setup.sh` is now POSIX `sh`.
- `tsconfig.json`: enabled `noUncheckedIndexedAccess` and `resolveJsonModule`.
## [1.0.1] - 2026-06-17
### Added
- End-user `install.sh` runnable via `curl ... | sh`. XDG-respecting, idempotent.
- `uninstall.sh` removes the wrapper and install tree; leaves Bun and user
config alone.
- `dev-setup.sh` for contributors.
### Removed
- `DISTRIBUTION-PLAN.md` (design notes, superseded by the implementation).
## [1.0.0] - 2026-06-15
Initial release.
### Added
- `move` CLI for keeping presence-tracking apps marked Available by nudging
the cursor after a configurable idle period.
- Flags: `-h/--help`, `-v/--version`, `-m/--move-interval`,
`-c/--check-interval`, `-d/--step-delay`, `-n/--step-count`, `-V/--verbose`.
- Quiet-by-default logging.
- Source split into `src/{move,cli,config,keeper}.ts`.
- `bin` entry + shebang so `bun link` registers `move` globally.
[1.3.0]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.2.0...v1.3.0
[1.2.0]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.1.1...v1.2.0
[1.1.1]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.1.0...v1.1.1
[1.1.0]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.0.1...v1.1.0
[1.0.1]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.0.0...v1.0.1
[1.0.0]: https://gitea.cahlen.com/nokeo08/Move/releases/tag/v1.0.0
+102 -20
View File
@@ -84,12 +84,16 @@ Usage: move [options]
Options: Options:
-h, --help Show this help and exit. -h, --help Show this help and exit.
-v, --version Print version and exit. -v, --version Print version and exit.
-e, --edit Open the config file in $EDITOR and exit.
-C, --config <path> Load defaults from a JSON config file. -C, --config <path> Load defaults from a JSON config file.
Default path: see the Configuration section. Default path: see the Configuration section.
-m, --move-interval <seconds> Idle time before a sweep fires. Default: 240. -m, --move-interval <seconds> Idle time before a sweep fires. Default: 240.
-c, --check-interval <seconds> Cursor poll cadence. Default: 10. -c, --check-interval <seconds> Cursor poll cadence. Default: 10.
-d, --step-delay <ms> Pause between synthetic steps. Default: 50. -d, --step-delay <ms> Pause between synthetic steps. Default: 50.
-n, --step-count <pixels> Steps per sweep. Default: 250. -p, --pattern <name> Movement strategy. Default: line.
One of: line, diagonal, jitter, walk, arc,
figureEight. Each pattern defines its own
size and speed.
-V, --verbose Log every sweep, interrupt, and bounds event -V, --verbose Log every sweep, interrupt, and bounds event
(default prints only the startup banner). (default prints only the startup banner).
@@ -145,15 +149,38 @@ doesn't set.
"moveInterval": 240, "moveInterval": 240,
"checkInterval": 10, "checkInterval": 10,
"stepDelay": 50, "stepDelay": 50,
"stepCount": 250, "pattern": "line",
"verbose": false "verbose": false
} }
``` ```
All keys are optional; supply only the ones you want to override. Keys All keys are optional; supply only the ones you want to override. Keys
and units mirror the CLI flags exactly: `moveInterval` and and units mirror the CLI flags exactly: `moveInterval` and
`checkInterval` are seconds, `stepDelay` is milliseconds, `stepCount` is `checkInterval` are seconds, `stepDelay` is milliseconds, `pattern` is a
pixels, `verbose` is a boolean. movement strategy name, `verbose` is a boolean.
> The obsolete `stepCount` / `stepSize` keys (removed in 1.3.0) are
> tolerated for backward compatibility: they're ignored with a one-line
> notice rather than rejected, so a config seeded by an older install keeps
> working. Sweep size and step count are now properties of each pattern.
### Editing
```sh
move -e # or --edit
move --edit --config /path/to/another.json
```
Opens the active config file in `$EDITOR` (honors flags in the value,
so `EDITOR="code --wait"` and `EDITOR=vim` both work). Refuses with
exit `2` if:
- `$EDITOR` is unset or empty.
- The target file doesn't exist. (Run `move` once or reinstall to
re-seed the default file.)
The editor's own exit code is propagated, so you can chain
`move -e && move` to validate-by-running after every edit.
### Validation ### Validation
@@ -162,6 +189,9 @@ The loader is strict:
- Root must be a JSON object. - Root must be a JSON object.
- Unknown keys are rejected (catches typos like `"movInterval"`). - Unknown keys are rejected (catches typos like `"movInterval"`).
- Numeric values must be finite and strictly positive. - Numeric values must be finite and strictly positive.
- `pattern` must resolve to a registered strategy name. Matching ignores
case and separators (`-`, `_`, spaces), so `figure-eight` and `figureEight`
are equivalent.
- `verbose` must be a boolean. - `verbose` must be a boolean.
Any validation failure prints a message naming the file and the offending Any validation failure prints a message naming the file and the offending
@@ -176,7 +206,7 @@ file with `--config`.
## How it works ## How it works
The source lives under `src/`, split into an entry point plus four logic The source lives under `src/`, split into an entry point plus logic
modules: modules:
- `src/move.ts` is a thin entry point: parses args, dispatches `--help` / - `src/move.ts` is a thin entry point: parses args, dispatches `--help` /
@@ -188,7 +218,22 @@ modules:
- `src/config.ts` exports the `Config` type (which carries every tunable - `src/config.ts` exports the `Config` type (which carries every tunable
including `verbose`), `DEFAULT_CONFIG`, `defaultConfigPath`, and the including `verbose`), `DEFAULT_CONFIG`, `defaultConfigPath`, and the
layered `resolveConfig` overlay function. layered `resolveConfig` overlay function.
- `src/keeper.ts` owns the synthetic-activity sweep and the idle-watch loop. - `src/keeper.ts` owns the idle-watch loop and the per-sweep glue that
wires a strategy to the executor.
Movement itself is split across three seams so patterns are easy to add
and everything but the raw nut.js call is unit-testable:
- `src/device.ts` is the I/O boundary: a `Device` interface
(`getPosition`/`setPosition`/`width`/`height`/`sleep`) plus the nut.js
implementation. It's the *only* module that imports nut.js, and it's
injectable, so tests drive the loop and executor with a fake.
- `src/strategies.ts` holds the pure movement patterns — each a generator
of target points given a start, screen size, config, and RNG — plus the
registry and name validation. Adding a pattern is one pure function.
- `src/executor.ts` is the single `executePath` driver: it rounds targets,
applies the strategy's bounds policy, paces steps, detects real-user
interruption, and restores the cursor on a clean sweep.
Defaults live in `src/config.ts` as `DEFAULT_CONFIG`: Defaults live in `src/config.ts` as `DEFAULT_CONFIG`:
@@ -197,7 +242,7 @@ Defaults live in `src/config.ts` as `DEFAULT_CONFIG`:
| `moveInterval` | `4 * 60_000` | `-m`, `--move-interval` | Idle time (ms) required before a synthetic sweep fires. | | `moveInterval` | `4 * 60_000` | `-m`, `--move-interval` | Idle time (ms) required before a synthetic sweep fires. |
| `checkInterval` | `10_000` | `-c`, `--check-interval` | How often (ms) the main loop polls the cursor for real activity. | | `checkInterval` | `10_000` | `-c`, `--check-interval` | How often (ms) the main loop polls the cursor for real activity. |
| `stepDelay` | `50` | `-d`, `--step-delay` | Pause (ms) between individual synthetic steps in a sweep. | | `stepDelay` | `50` | `-d`, `--step-delay` | Pause (ms) between individual synthetic steps in a sweep. |
| `stepCount` | `250` | `-n`, `--step-count` | Pixel-steps per sweep. | | `pattern` | `"line"` | `-p`, `--pattern` | Movement strategy name (see Movement strategies below). |
`-m` and `-c` are accepted in seconds at the CLI; `resolveConfig` converts `-m` and `-c` are accepted in seconds at the CLI; `resolveConfig` converts
to milliseconds before handing the resolved `Config` to `runKeeper`. to milliseconds before handing the resolved `Config` to `runKeeper`.
@@ -212,27 +257,59 @@ to milliseconds before handing the resolved `Config` to `runKeeper`.
- Otherwise, if `now - lastActivity >= config.moveInterval`, call - Otherwise, if `now - lastActivity >= config.moveInterval`, call
`simulateActivity` and reset the idleness clock. `simulateActivity` and reset the idleness clock.
### Synthetic sweep (`simulateActivity`) ### Synthetic sweep (`simulateActivity` + `executePath`)
1. Read the starting position and current screen dimensions. 1. `simulateActivity` snapshots the starting position and current screen
2. Pick a horizontal direction (`dx = +1` if there's room to the right, dimensions (re-read every sweep so monitor changes are handled), looks
else `-1`) so the sweep stays on-screen. Vertical is `dy = 0` for now. up `config.pattern` in the strategy registry, and builds a `MoveContext`.
3. For each of `config.stepCount` steps: 2. It hands the strategy and context to `executePath`, which drives the
- Compute and bounds-check the next target. sweep. For each target the strategy yields:
- Round to whole pixels and apply the strategy's bounds policy
(`abort` / `clamp` / `reflect`) to keep it on-screen.
- Move the cursor there, sleep `config.stepDelay`. - Move the cursor there, sleep `config.stepDelay`.
- Re-read the cursor. If it isn't where we put it, the user moved it — - Re-read the cursor. If it isn't at the point we *just commanded*, the
log (when `--verbose`) and return early without snapping back. user moved it — log (when `--verbose`) and return early without
4. On a clean full sweep, restore the cursor to its starting position so snapping back.
3. On a clean full sweep, restore the cursor to its starting position so
the next idle-check sees "no movement" and doesn't misread the synthetic the next idle-check sees "no movement" and doesn't misread the synthetic
activity as real user input. activity as real user input.
Comparing against the last commanded (rounded) point — not the strategy's
ideal, possibly fractional target — is what lets curved and stochastic
patterns run without every rounded step looking like user activity. The
comparison also allows a small (2px) tolerance, and the `clamp`/`reflect`
patterns stay a couple of pixels off the screen edge, so sub-pixel cursor
placement on scaled or multi-monitor displays isn't misread as the user
grabbing the mouse. `line` uses the `abort` policy and is unaffected.
### Movement strategies
`config.pattern` selects one of the generators in `src/strategies.ts`:
| Name | Motion | Steps | Size | Bounds |
| ------------- | ------------------------------------------------------------- | ----- | -------- | --------- |
| `line` | Straight horizontal sweep (the original behavior). | 250 | 250px | `abort` |
| `diagonal` | Straight line on both axes toward the roomiest corner. | 250 | 250px/axis | `clamp` |
| `jitter` | Small random hops within a tight radius of the start. | 80 | 30px radius | `clamp` |
| `walk` | Cumulative random walk; bounces off the screen edges. | 200 | ±4px/step | `reflect` |
| `arc` | Smooth quadratic-Bézier curve to a random on-screen point. | 120 | ~300px | `clamp` |
| `figureEight` | Traces a figure-eight (lemniscate) and returns to the start. | 90 | ~250px wide | `clamp` |
Each pattern owns its geometry — how many steps it takes and how far it
reaches — as constants in `src/strategies.ts`. Those are properties of the
pattern, not user preferences, so there is no knob for sweep size or step
count; `stepDelay` (the per-step pause) is the only pacing lever, and it
scales every pattern's total duration. To add a pattern, write one pure
generator and register it — the executor supplies bounds, pacing, interrupt,
and restore for free.
### Why `mouse.config.autoDelayMs = 0` ### Why `mouse.config.autoDelayMs = 0`
nut.js inserts a 100ms delay after every action by default. With two mouse nut.js inserts a 100ms delay after every action by default. With two mouse
calls per step that would silently more-than-double the duration of a calls per step that would silently more-than-double the duration of a
sweep. The script controls cadence itself via `config.stepDelay`, so the sweep. The code controls cadence itself via `config.stepDelay`, so the
implicit delay is disabled at module load (a side effect of importing implicit delay is disabled in `createNutDevice` — the single place nut.js
`keeper.ts`). is wired up. Importing the movement modules stays side-effect-free.
## For contributors ## For contributors
@@ -275,7 +352,12 @@ move --help
| `src/cli.ts` | Argument parsing, validation, and help/version output. | | `src/cli.ts` | Argument parsing, validation, and help/version output. |
| `src/config.ts` | `Config` type (carries every tunable, including `verbose`), `DEFAULT_CONFIG` (derived from `scripts/config.default.json`), `defaultConfigPath`, and the layered `resolveConfig` overlay. | | `src/config.ts` | `Config` type (carries every tunable, including `verbose`), `DEFAULT_CONFIG` (derived from `scripts/config.default.json`), `defaultConfigPath`, and the layered `resolveConfig` overlay. |
| `src/configFile.ts` | Optional JSON config-file loader with strict schema validation. | | `src/configFile.ts` | Optional JSON config-file loader with strict schema validation. |
| `src/keeper.ts` | Synthetic-activity sweep and idle-watch loop. | | `src/editor.ts` | `move --edit`: opens the active config file in `$EDITOR`. |
| `src/errors.ts` | Shared error types (`CliError`). |
| `src/keeper.ts` | Idle-watch loop + per-sweep glue (selects a strategy, calls the executor). |
| `src/device.ts` | `Device` I/O seam over nut.js (`Point`, `createNutDevice`); the only nut.js importer. |
| `src/strategies.ts` | Pure movement-pattern generators, the strategy registry, and name validation. |
| `src/executor.ts` | `executePath` driver: bounds policy, pacing, interrupt detection, restore. |
| `package.json` | Bun project manifest. Single runtime dep: `@nut-tree-fork/nut-js`. | | `package.json` | Bun project manifest. Single runtime dep: `@nut-tree-fork/nut-js`. |
| `tsconfig.json` | Strict TypeScript config tuned for Bun (ESNext, bundler resolution). | | `tsconfig.json` | Strict TypeScript config tuned for Bun (ESNext, bundler resolution). |
| `bun.lock` | Bun's lockfile. Commit this. | | `bun.lock` | Bun's lockfile. Commit this. |
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "move", "name": "move",
"version": "1.1.1", "version": "1.3.0",
"private": true, "private": true,
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"type": "module", "type": "module",
+1 -1
View File
@@ -2,6 +2,6 @@
"moveInterval": 240, "moveInterval": 240,
"checkInterval": 10, "checkInterval": 10,
"stepDelay": 50, "stepDelay": 50,
"stepCount": 250, "pattern": "line",
"verbose": false "verbose": false
} }
+31 -5
View File
@@ -9,11 +9,14 @@
* *
* -h, --help Prints `printHelp()` to stdout; entry exits 0. * -h, --help Prints `printHelp()` to stdout; entry exits 0.
* -v, --version Prints `move <VERSION>` to stdout; entry exits 0. * -v, --version Prints `move <VERSION>` to stdout; entry exits 0.
* -e, --edit Open the active config file in `$EDITOR`.
* Refuses if the file doesn't exist; refuses if
* `$EDITOR` is unset.
* -C, --config <path> Override the default config-file path. * -C, --config <path> Override the default config-file path.
* -m, --move-interval Idle time (seconds) before a sweep fires. * -m, --move-interval Idle time (seconds) before a sweep fires.
* -c, --check-interval Cursor poll cadence (seconds). * -c, --check-interval Cursor poll cadence (seconds).
* -d, --step-delay Pause between synthetic steps (ms). * -d, --step-delay Pause between synthetic steps (ms).
* -n, --step-count Steps per sweep (pixels). * -p, --pattern Movement strategy name (see strategies.ts).
* -V, --verbose Enable per-sweep / interrupt / bounds logging. * -V, --verbose Enable per-sweep / interrupt / bounds logging.
* (`-V` capital because `-v` is `--version`.) * (`-V` capital because `-v` is `--version`.)
* *
@@ -28,6 +31,7 @@ import { parseArgs } from "node:util";
import { DEFAULT_CONFIG, defaultConfigPath } from "./config.ts"; import { DEFAULT_CONFIG, defaultConfigPath } from "./config.ts";
import { CliError } from "./errors.ts"; import { CliError } from "./errors.ts";
import { PATTERN_NAMES, resolvePatternName } from "./strategies.ts";
/** /**
* Result of `parseCliArgs`. Numeric fields are `undefined` when the user * Result of `parseCliArgs`. Numeric fields are `undefined` when the user
@@ -37,11 +41,13 @@ import { CliError } from "./errors.ts";
export interface ParsedCliArgs { export interface ParsedCliArgs {
help: boolean; help: boolean;
version: boolean; version: boolean;
edit: boolean;
config: string | undefined; config: string | undefined;
moveInterval: number | undefined; // seconds moveInterval: number | undefined; // seconds
checkInterval: number | undefined; // seconds checkInterval: number | undefined; // seconds
stepDelay: number | undefined; // milliseconds stepDelay: number | undefined; // milliseconds
stepCount: number | undefined; // pixels /** Movement strategy name, validated against the registry. */
pattern: string | undefined;
/** /**
* `true` when `-V`/`--verbose` was passed; `undefined` when it was not. * `true` when `-V`/`--verbose` was passed; `undefined` when it was not.
* `undefined` (not `false`) lets the layered resolver distinguish "user * `undefined` (not `false`) lets the layered resolver distinguish "user
@@ -65,6 +71,20 @@ function parsePositiveNumber(name: string, raw: string | undefined): number | un
return n; return n;
} }
/**
* Validate a CLI-supplied movement-pattern name. Returns `undefined` when
* the flag was not supplied; throws `CliError` naming the valid patterns
* when the value isn't a registered strategy.
*/
function parsePatternName(raw: string | undefined): string | undefined {
if (raw === undefined) return undefined;
const canonical: string | null = resolvePatternName(raw);
if (canonical === null) {
throw new CliError(`invalid value for --pattern: '${raw}' (valid: ${PATTERN_NAMES.join(", ")})`);
}
return canonical;
}
/** /**
* Parse `process.argv` into a typed `ParsedCliArgs`. Uses Node's built-in * Parse `process.argv` into a typed `ParsedCliArgs`. Uses Node's built-in
* `parseArgs` in strict mode so unknown flags and missing values surface * `parseArgs` in strict mode so unknown flags and missing values surface
@@ -78,11 +98,12 @@ export function parseCliArgs(): ParsedCliArgs {
options: { options: {
help: { type: "boolean", short: "h" }, help: { type: "boolean", short: "h" },
version: { type: "boolean", short: "v" }, version: { type: "boolean", short: "v" },
edit: { type: "boolean", short: "e" },
config: { type: "string", short: "C" }, config: { type: "string", short: "C" },
"move-interval": { type: "string", short: "m" }, "move-interval": { type: "string", short: "m" },
"check-interval": { type: "string", short: "c" }, "check-interval": { type: "string", short: "c" },
"step-delay": { type: "string", short: "d" }, "step-delay": { type: "string", short: "d" },
"step-count": { type: "string", short: "n" }, pattern: { type: "string", short: "p" },
verbose: { type: "boolean", short: "V" }, verbose: { type: "boolean", short: "V" },
}, },
strict: true, strict: true,
@@ -99,11 +120,12 @@ export function parseCliArgs(): ParsedCliArgs {
return { return {
help: Boolean(values.help), help: Boolean(values.help),
version: Boolean(values.version), version: Boolean(values.version),
edit: Boolean(values.edit),
config: values.config as string | undefined, config: values.config as string | undefined,
moveInterval: parsePositiveNumber("move-interval", values["move-interval"] as string | undefined), moveInterval: parsePositiveNumber("move-interval", values["move-interval"] as string | undefined),
checkInterval: parsePositiveNumber("check-interval", values["check-interval"] as string | undefined), checkInterval: parsePositiveNumber("check-interval", values["check-interval"] as string | undefined),
stepDelay: parsePositiveNumber("step-delay", values["step-delay"] as string | undefined), stepDelay: parsePositiveNumber("step-delay", values["step-delay"] as string | undefined),
stepCount: parsePositiveNumber("step-count", values["step-count"] as string | undefined), pattern: parsePatternName(values.pattern as string | undefined),
verbose: values.verbose === true ? true : undefined, verbose: values.verbose === true ? true : undefined,
}; };
} }
@@ -140,12 +162,15 @@ nudging the mouse cursor after a configurable idle period.
Options: Options:
-h, --help Show this help and exit. -h, --help Show this help and exit.
-v, --version Print version and exit. -v, --version Print version and exit.
-e, --edit Open the config file in $EDITOR and exit.
-C, --config <path> Load defaults from a JSON config file. -C, --config <path> Load defaults from a JSON config file.
Default path: ${cfgPath} Default path: ${cfgPath}
-m, --move-interval <seconds> Idle time before a sweep fires. Default: ${moveDefaultSec}. -m, --move-interval <seconds> Idle time before a sweep fires. Default: ${moveDefaultSec}.
-c, --check-interval <seconds> Cursor poll cadence. Default: ${checkDefaultSec}. -c, --check-interval <seconds> Cursor poll cadence. Default: ${checkDefaultSec}.
-d, --step-delay <ms> Pause between synthetic steps. Default: ${DEFAULT_CONFIG.stepDelay}. -d, --step-delay <ms> Pause between synthetic steps. Default: ${DEFAULT_CONFIG.stepDelay}.
-n, --step-count <pixels> Steps per sweep. Default: ${DEFAULT_CONFIG.stepCount}. -p, --pattern <name> Movement strategy. Default: ${DEFAULT_CONFIG.pattern}.
One of: ${PATTERN_NAMES.join(", ")}.
Each pattern defines its own size and speed.
-V, --verbose Log every sweep, interrupt, and bounds event -V, --verbose Log every sweep, interrupt, and bounds event
(default prints only the startup banner). (default prints only the startup banner).
@@ -155,6 +180,7 @@ Examples:
move move
move --move-interval 180 --check-interval 5 move --move-interval 180 --check-interval 5
move -m 300 -V move -m 300 -V
move --pattern arc
move --config ~/myprofile.json move --config ~/myprofile.json
`); `);
} }
+18 -12
View File
@@ -22,12 +22,13 @@
import { join } from "node:path"; import { join } from "node:path";
import { CliError } from "./errors.ts"; import { CliError } from "./errors.ts";
import { isPatternName, type PatternName } from "./strategies.ts";
// Single source of truth for default values. The same file ships in the // Single source of truth for default values. The same file ships in the
// install tree and is copied to $XDG_CONFIG_HOME/move/config.json on a // install tree and is copied to $XDG_CONFIG_HOME/move/config.json on a
// fresh install (only if no config exists there yet). Values use the CLI // fresh install (only if no config exists there yet). Values use the CLI
// units (seconds for time fields, ms for stepDelay, pixels for stepCount); // units (seconds for time fields, ms for stepDelay); the seconds->ms
// the seconds->ms conversion happens below where DEFAULT_CONFIG is built. // conversion happens below where DEFAULT_CONFIG is built.
import seedRaw from "../scripts/config.default.json" with { type: "json" }; import seedRaw from "../scripts/config.default.json" with { type: "json" };
/** /**
@@ -41,7 +42,9 @@ import seedRaw from "../scripts/config.default.json" with { type: "json" };
* - `stepDelay` — pause between individual synthetic mouse steps inside * - `stepDelay` — pause between individual synthetic mouse steps inside
* a sweep. Also the window in which the user can * a sweep. Also the window in which the user can
* "interrupt" by moving the cursor. Milliseconds. * "interrupt" by moving the cursor. Milliseconds.
* - `stepCount` — number of pixel-steps in a single sweep. Pixels. * - `pattern` — name of the movement strategy to use (see
* `strategies.ts`; e.g. `line`, `walk`, `arc`). Each
* pattern owns its own size and step count.
* - `verbose` — whether per-sweep / interrupt / bounds events are * - `verbose` — whether per-sweep / interrupt / bounds events are
* logged. The startup banner is always printed. * logged. The startup banner is always printed.
*/ */
@@ -49,7 +52,7 @@ export interface Config {
readonly moveInterval: number; readonly moveInterval: number;
readonly checkInterval: number; readonly checkInterval: number;
readonly stepDelay: number; readonly stepDelay: number;
readonly stepCount: number; readonly pattern: PatternName;
readonly verbose: boolean; readonly verbose: boolean;
} }
@@ -63,7 +66,7 @@ interface SeedShape {
moveInterval: number; // seconds moveInterval: number; // seconds
checkInterval: number; // seconds checkInterval: number; // seconds
stepDelay: number; // milliseconds stepDelay: number; // milliseconds
stepCount: number; // pixels pattern: string; // strategy name
verbose: boolean; verbose: boolean;
} }
@@ -72,12 +75,15 @@ function assertSeedShape(raw: unknown): asserts raw is SeedShape {
throw new Error("scripts/config.default.json: root must be an object"); throw new Error("scripts/config.default.json: root must be an object");
} }
const r = raw as Record<string, unknown>; const r = raw as Record<string, unknown>;
for (const key of ["moveInterval", "checkInterval", "stepDelay", "stepCount"] as const) { for (const key of ["moveInterval", "checkInterval", "stepDelay"] as const) {
const v = r[key]; const v = r[key];
if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) { if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) {
throw new Error(`scripts/config.default.json: '${key}' must be a positive finite number (got ${JSON.stringify(v)})`); throw new Error(`scripts/config.default.json: '${key}' must be a positive finite number (got ${JSON.stringify(v)})`);
} }
} }
if (typeof r.pattern !== "string" || !isPatternName(r.pattern)) {
throw new Error(`scripts/config.default.json: 'pattern' must be a known strategy name (got ${JSON.stringify(r.pattern)})`);
}
if (typeof r.verbose !== "boolean") { if (typeof r.verbose !== "boolean") {
throw new Error(`scripts/config.default.json: 'verbose' must be a boolean (got ${JSON.stringify(r.verbose)})`); throw new Error(`scripts/config.default.json: 'verbose' must be a boolean (got ${JSON.stringify(r.verbose)})`);
} }
@@ -97,7 +103,7 @@ export const DEFAULT_CONFIG: Config = {
moveInterval: seed.moveInterval * 1000, moveInterval: seed.moveInterval * 1000,
checkInterval: seed.checkInterval * 1000, checkInterval: seed.checkInterval * 1000,
stepDelay: seed.stepDelay, stepDelay: seed.stepDelay,
stepCount: seed.stepCount, pattern: seed.pattern,
verbose: seed.verbose, verbose: seed.verbose,
}; };
@@ -110,10 +116,10 @@ export const DEFAULT_CONFIG: Config = {
* Numeric fields are in CLI / config-file units: * Numeric fields are in CLI / config-file units:
* moveInterval, checkInterval — seconds * moveInterval, checkInterval — seconds
* stepDelay — milliseconds * stepDelay — milliseconds
* stepCount — pixels
* *
* `verbose` is `boolean | undefined` like the numeric fields, so all five * `pattern` is a strategy name (`string | undefined`) and `verbose` is
* fields share the same "first defined value wins" precedence logic. * `boolean | undefined`, so every field shares the same "first defined
* value wins" precedence logic.
* *
* For the CLI specifically, `verbose` is `undefined` when `-V/--verbose` * For the CLI specifically, `verbose` is `undefined` when `-V/--verbose`
* was not passed and `true` when it was. There is no CLI off-switch * was not passed and `true` when it was. There is no CLI off-switch
@@ -125,7 +131,7 @@ export interface ConfigOverrides {
readonly moveInterval: number | undefined; readonly moveInterval: number | undefined;
readonly checkInterval: number | undefined; readonly checkInterval: number | undefined;
readonly stepDelay: number | undefined; readonly stepDelay: number | undefined;
readonly stepCount: number | undefined; readonly pattern: string | undefined;
readonly verbose: boolean | undefined; readonly verbose: boolean | undefined;
} }
@@ -191,7 +197,7 @@ export function resolveConfig(file: ConfigOverrides | null, cli: ConfigOverrides
moveInterval: pickSeconds(cli.moveInterval, file?.moveInterval, DEFAULT_CONFIG.moveInterval), moveInterval: pickSeconds(cli.moveInterval, file?.moveInterval, DEFAULT_CONFIG.moveInterval),
checkInterval: pickSeconds(cli.checkInterval, file?.checkInterval, DEFAULT_CONFIG.checkInterval), checkInterval: pickSeconds(cli.checkInterval, file?.checkInterval, DEFAULT_CONFIG.checkInterval),
stepDelay: pickRaw(cli.stepDelay, file?.stepDelay, DEFAULT_CONFIG.stepDelay), stepDelay: pickRaw(cli.stepDelay, file?.stepDelay, DEFAULT_CONFIG.stepDelay),
stepCount: pickRaw(cli.stepCount, file?.stepCount, DEFAULT_CONFIG.stepCount), pattern: pickRaw(cli.pattern, file?.pattern, DEFAULT_CONFIG.pattern),
verbose: pickRaw(cli.verbose, file?.verbose, DEFAULT_CONFIG.verbose), verbose: pickRaw(cli.verbose, file?.verbose, DEFAULT_CONFIG.verbose),
}; };
} }
+48 -9
View File
@@ -10,12 +10,14 @@
* moveInterval number seconds, positive * moveInterval number seconds, positive
* checkInterval number seconds, positive * checkInterval number seconds, positive
* stepDelay number milliseconds, positive * stepDelay number milliseconds, positive
* stepCount number pixels, positive * pattern string a registered strategy name
* verbose boolean * verbose boolean
* *
* Unknown keys, wrong types, and non-positive numerics are rejected with a * Unknown keys, wrong types, and non-positive numerics are rejected with a
* `CliError` so the entry point can exit 2 (user error) with a clear * `CliError` so the entry point can exit 2 (user error) with a clear
* message pointing at the offending file. * message pointing at the offending file. The removed `stepCount` /
* `stepSize` keys are the exception: they're tolerated (ignored with a
* one-line notice) so an older seeded config keeps working after upgrade.
* *
* Return semantics: * Return semantics:
* - `null` when no `explicitPath` was passed and the default path does * - `null` when no `explicitPath` was passed and the default path does
@@ -29,15 +31,29 @@ import { existsSync, readFileSync, statSync } from "node:fs";
import { defaultConfigPath, type ConfigOverrides } from "./config.ts"; import { defaultConfigPath, type ConfigOverrides } from "./config.ts";
import { CliError } from "./errors.ts"; import { CliError } from "./errors.ts";
import { PATTERN_NAMES, resolvePatternName } from "./strategies.ts";
const ALLOWED_KEYS: ReadonlySet<string> = new Set<string>([ const ALLOWED_KEYS: ReadonlySet<string> = new Set<string>([
"moveInterval", "moveInterval",
"checkInterval", "checkInterval",
"stepDelay", "stepDelay",
"stepCount", "pattern",
"verbose", "verbose",
]); ]);
/**
* Keys that used to be valid but have since been removed. They're tolerated
* (not rejected like a genuine unknown key) so upgrading doesn't hard-fail a
* config that was seeded with them — every pre-1.3.0 install has `stepCount`
* in its file. They no longer do anything: sweep size and step count are now
* properties of each movement pattern. A one-line notice points the user at
* the file so they can remove them at leisure.
*/
const DEPRECATED_KEYS: ReadonlySet<string> = new Set<string>([
"stepCount",
"stepSize",
]);
function isPlainObject(value: unknown): value is Record<string, unknown> { function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); return typeof value === "object" && value !== null && !Array.isArray(value);
} }
@@ -60,6 +76,16 @@ function requireBoolean(name: string, raw: unknown, path: string): boolean {
return raw; return raw;
} }
function requirePatternName(name: string, raw: unknown, path: string): string {
const canonical: string | null = typeof raw === "string" ? resolvePatternName(raw) : null;
if (canonical === null) {
throw new CliError(
`invalid value for '${name}' in ${path}: ${JSON.stringify(raw)} (valid: ${PATTERN_NAMES.join(", ")})`,
);
}
return canonical;
}
/** /**
* Load and validate the config file. See module docstring for return * Load and validate the config file. See module docstring for return
* semantics. * semantics.
@@ -104,13 +130,26 @@ export function loadConfigFile(explicitPath: string | undefined): ConfigOverride
throw new CliError(`config file ${path} must contain a JSON object at the root`); throw new CliError(`config file ${path} must contain a JSON object at the root`);
} }
// Strict mode: reject any key we don't know about. Catches typos like // Strict mode: reject any key we don't know about (catches typos like
// 'movInterval' that would otherwise sail through silently. // 'movInterval'), but tolerate keys we've since removed — collect those
// and warn once, rather than hard-failing a config seeded by an older
// install.
const deprecatedFound: string[] = [];
for (const key of Object.keys(parsed)) { for (const key of Object.keys(parsed)) {
if (!ALLOWED_KEYS.has(key)) { if (ALLOWED_KEYS.has(key)) continue;
if (DEPRECATED_KEYS.has(key)) {
deprecatedFound.push(key);
continue;
}
const allowed: string = [...ALLOWED_KEYS].join(", "); const allowed: string = [...ALLOWED_KEYS].join(", ");
throw new CliError(`unknown key '${key}' in ${path} (allowed: ${allowed})`); throw new CliError(`unknown key '${key}' in ${path} (allowed: ${allowed})`);
} }
if (deprecatedFound.length > 0) {
const names: string = deprecatedFound.map((k) => `'${k}'`).join(", ");
process.stderr.write(
`move: ignoring obsolete key(s) ${names} in ${path}\n` +
` (sweep size is now defined by each movement pattern)\n`,
);
} }
return { return {
@@ -126,9 +165,9 @@ export function loadConfigFile(explicitPath: string | undefined): ConfigOverride
"stepDelay" in parsed "stepDelay" in parsed
? requirePositiveNumber("stepDelay", parsed.stepDelay, path) ? requirePositiveNumber("stepDelay", parsed.stepDelay, path)
: undefined, : undefined,
stepCount: pattern:
"stepCount" in parsed "pattern" in parsed
? requirePositiveNumber("stepCount", parsed.stepCount, path) ? requirePatternName("pattern", parsed.pattern, path)
: undefined, : undefined,
verbose: verbose:
"verbose" in parsed "verbose" in parsed
+89
View File
@@ -0,0 +1,89 @@
/**
* device.ts
* ---------
* The I/O seam between the movement machinery and the outside world.
*
* Everything that actually touches `@nut-tree-fork/nut-js` lives here and
* nowhere else. The strategies (`strategies.ts`) and the execution driver
* (`executor.ts`) are written against the `Device` interface, which makes
* them pure and unit-testable without the nut.js native binary or a real
* screen — a fake `Device` is enough.
*
* `Point` is deliberately a plain `{ x, y }` structure rather than nut.js's
* `Point` class, so no module outside this one has to import nut.js just to
* describe a coordinate. `createNutDevice` converts to nut.js's `Point`
* when it commands the cursor.
*/
/**
* A screen coordinate in pixels. Plain data (not nut.js's `Point` class) so
* strategies, the executor, and tests never need a nut.js import.
*/
export interface Point {
readonly x: number;
readonly y: number;
}
/**
* The capabilities the movement machinery needs from the host system:
* read/write the cursor, learn the screen size, and wait.
*
* The production implementation (`createNutDevice`) is backed by nut.js;
* tests substitute a fake that records calls and returns scripted values.
*/
export interface Device {
/** Current cursor position. */
getPosition(): Promise<Point>;
/** Move the cursor to `p`. */
setPosition(p: Point): Promise<void>;
/** Current primary-screen width in pixels. */
width(): Promise<number>;
/** Current primary-screen height in pixels. */
height(): Promise<number>;
/** Resolve after `ms` milliseconds. */
sleep(ms: number): Promise<void>;
}
/**
* Promise-based `setTimeout`. Shared default sleep used by the nut.js
* device and available for reuse.
*
* @param ms - Duration to wait, in milliseconds.
*/
export const sleep = (ms: number): Promise<void> =>
new Promise<void>((resolve: () => void): void => {
setTimeout(resolve, ms);
});
/**
* Build the production `Device` backed by nut.js.
*
* Importing nut.js dlopens a sizeable native `.node` binary, so this is a
* function (not a module-level singleton): callers that never move the
* mouse (`--help`, `--version`) never pay for it, and `move.ts` already
* defers the whole `keeper.ts` import behind those short-circuits.
*
* Side effect: sets `mouse.config.autoDelayMs = 0`. nut.js otherwise
* inserts a 100ms delay after every action, which — with two cursor calls
* per step — would silently more-than-double every sweep. We drive cadence
* ourselves via `stepDelay`, so the implicit delay is disabled here, at the
* single point where nut.js is actually wired up.
*/
export async function createNutDevice(): Promise<Device> {
const { mouse, Point: NutPoint, screen } = await import("@nut-tree-fork/nut-js");
mouse.config.autoDelayMs = 0;
return {
getPosition: async (): Promise<Point> => {
const p = await mouse.getPosition();
return { x: p.x, y: p.y };
},
setPosition: async (p: Point): Promise<void> => {
await mouse.setPosition(new NutPoint(p.x, p.y));
},
width: (): Promise<number> => screen.width(),
height: (): Promise<number> => screen.height(),
sleep,
};
}
+83
View File
@@ -0,0 +1,83 @@
/**
* editor.ts
* ---------
* `move --edit` support: open the active config file in `$EDITOR`.
*
* Refuses (CliError -> exit 2) when:
* - `$EDITOR` is unset or empty.
* - The target config file does not exist.
*
* Otherwise spawns `$EDITOR <path>` with the terminal attached, waits for
* it to exit, and propagates its exit code.
*
* Editor command parsing: `$EDITOR` is often a single word (`vim`,
* `nano`) but can include flags (`code --wait`, `emacs -nw`). We delegate
* to the shell so the value's own quoting / word-splitting Just Works:
*
* sh -c '<editor> "$@"' -- <path>
*
* The editor string is interpolated into the script body, so the shell
* tokenizes it normally (splitting `code --wait` into argv elements).
* The `--` placeholder takes the `$0` slot so `"$@"` is just our path.
* Same approach git uses to invoke `GIT_EDITOR`.
*
* Caveat: because `$EDITOR` is interpolated, shell metacharacters in its
* value WILL be interpreted (this matches git/vipe/most tools). That is a
* non-issue under the standard threat model — the user sets `$EDITOR`
* themselves — and would be impossible to handle differently without
* writing our own POSIX tokenizer.
*/
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { CliError } from "./errors.ts";
/**
* Pure helper that builds the argv we hand to the shell. Extracted so
* `editor.test.ts` can verify the construction without actually launching
* an editor.
*/
export function editorCommand(editor: string, path: string): readonly string[] {
// Editor is interpolated into the script body so the shell tokenizes
// multi-word values like 'code --wait'. The '--' takes the $0 slot;
// path becomes $1 / "$@".
return ["sh", "-c", `${editor} "$@"`, "--", path];
}
/**
* Launch `$EDITOR` on the given config path. Never returns: exits with the
* editor's status code (or 1 if it was killed by a signal).
*/
export function editConfig(path: string): never {
const editor = process.env.EDITOR;
if (editor === undefined || editor.length === 0) {
throw new CliError(
"$EDITOR is not set. Set it (e.g., 'export EDITOR=vim') and re-run.",
);
}
if (!existsSync(path)) {
throw new CliError(
`no config file at ${path}. Run 'move' once to start using defaults, or reinstall to re-seed the file.`,
);
}
// We build the argv via the pure helper, then unpack to satisfy
// spawnSync's (command, args, options) signature.
const argv: readonly string[] = editorCommand(editor, path);
const [command, ...args] = argv;
if (command === undefined) {
// Defensive: editorCommand always returns a non-empty array.
throw new CliError("internal: editor command construction produced an empty argv");
}
const result = spawnSync(command, args, { stdio: "inherit" });
if (result.error !== undefined) {
throw new CliError(`failed to launch $EDITOR: ${result.error.message}`);
}
// status is `number | null` (null when signal-killed). Exit 1 in the
// null case so the caller sees a non-zero, machine-readable status.
process.exit(result.status ?? 1);
}
+197
View File
@@ -0,0 +1,197 @@
/**
* executor.ts
* -----------
* The single execution driver shared by every movement strategy.
*
* A strategy (`strategies.ts`) says *where* to go; this module owns
* *everything else* about carrying a sweep out against a `Device`:
*
* - round each ideal target to whole pixels,
* - keep it on-screen per the strategy's `BoundsPolicy`,
* - command the cursor and pace it with `stepDelay`,
* - detect real-user interruption after each step,
* - restore the cursor to the origin on a clean run.
*
* Writing this once means new patterns inherit correct real-user-wins,
* bounds, and restore semantics for free. It's pure with respect to I/O —
* all side effects go through the injected `Device`, so it's unit-testable
* with a fake.
*
* Interrupt detection compares the re-read cursor against the *last
* commanded (rounded) point*, never the strategy's ideal (possibly
* fractional) target. That's what lets curved/stochastic patterns work
* without every rounded step being misread as "the user moved the mouse".
*/
import type { Config } from "./config.ts";
import type { Device, Point } from "./device.ts";
import type { BoundsPolicy, MoveContext, MovementStrategy } from "./strategies.ts";
/**
* Minimal log surface used by the executor and the keeper loop.
*
* - `info(msg)` prints unconditionally (startup banner, fatal notes).
* - `event(msg)` prints only under `--verbose` / `verbose: true`.
*/
export interface Logger {
info(msg: string): void;
event(msg: string): void;
}
/**
* How a sweep ended:
* - `completed` — full path ran and the cursor was restored to start.
* - `interrupted` — real user activity detected mid-sweep; aborted without
* snapping back.
* - `aborted` — an `abort`-policy target went out of bounds.
*/
export type SweepOutcome = "completed" | "interrupted" | "aborted";
/**
* Slack, in pixels, allowed between the coordinate we commanded and the one
* we read back before calling it real-user activity. Absorbs the sub-pixel
* placement error the OS can introduce on scaled or multi-monitor setups; a
* genuine user movement is far larger than this.
*/
const READBACK_TOLERANCE: number = 2;
/**
* Pixels to inset the `clamp` / `reflect` travel range from each screen edge.
* Keeps edge-seeking patterns off the literal first/last pixel, where DPI
* scaling and multi-monitor boundaries most often make the OS place the
* cursor a hair off what we commanded (which the readback check would then
* misread as the user). `abort` (used by `line`) is deliberately left on the
* full `[0, max - 1]` range, so its behavior is unchanged.
*/
const EDGE_MARGIN: number = 2;
/**
* The inclusive `[lo, hi]` integer range an axis of length `max` may travel
* under the `clamp` / `reflect` policies: `[0, max - 1]` inset by
* `EDGE_MARGIN` on each side. Screens too small to inset fall back to the
* full range so the math never inverts.
*/
function travelRange(max: number): { lo: number; hi: number } {
const hiEdge: number = max - 1;
if (hiEdge - 2 * EDGE_MARGIN < 1) return { lo: 0, hi: Math.max(0, hiEdge) };
return { lo: EDGE_MARGIN, hi: hiEdge - EDGE_MARGIN };
}
/** Round to whole pixels and clamp into the inset travel range for `max`. */
function clampInt(v: number, max: number): number {
const { lo, hi } = travelRange(max);
const r: number = Math.round(v);
if (r < lo) return lo;
if (r > hi) return hi;
return r;
}
/**
* Mirror `v` into the inset travel range for `max` as a triangle wave, so
* values past an edge bounce back inside instead of clamping flat against it.
*/
function reflectInt(v: number, max: number): number {
const { lo, hi } = travelRange(max);
const span: number = hi - lo;
if (span <= 0) return lo;
const period: number = 2 * span;
const m: number = (((Math.round(v) - lo) % period) + period) % period;
return lo + (m <= span ? m : period - m);
}
/**
* Resolve a strategy's ideal target to an on-screen integer pixel under the
* given policy. Returns `null` when policy is `abort` and the (rounded)
* target lies outside the screen — the signal to stop the sweep.
*/
function resolveTarget(
policy: BoundsPolicy,
p: Point,
width: number,
height: number,
): Point | null {
if (policy === "reflect") {
return { x: reflectInt(p.x, width), y: reflectInt(p.y, height) };
}
if (policy === "clamp") {
return { x: clampInt(p.x, width), y: clampInt(p.y, height) };
}
// abort: round, then reject anything off-screen.
const x: number = Math.round(p.x);
const y: number = Math.round(p.y);
if (x < 0 || x >= width || y < 0 || y >= height) return null;
return { x, y };
}
/**
* Format the current local time as `HH:MM:SS` for log lines.
*/
function timestamp(): string {
const d: Date = new Date();
const pad = (n: number): string => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
/**
* Run one sweep: drive `strategy.path(ctx)` to completion (or early exit)
* against `device`.
*
* Contract, per step:
* 1. Resolve the ideal target to an on-screen integer (bounds policy).
* An `abort`-policy out-of-bounds target ends the sweep (`aborted`).
* 2. Command the cursor there and sleep `config.stepDelay` — also the
* user's interrupt window.
* 3. Re-read the cursor. If it isn't at the point we just commanded, the
* user moved it: return `interrupted` without restoring.
*
* On a clean run the cursor is restored to `ctx.start` so the next
* idle-check sees no net movement, and `completed` is returned.
*
* `config` supplies only the pacing (`stepDelay`); a strategy's geometry is
* entirely self-contained, so the path itself needs nothing from it.
*/
export async function executePath(
strategy: MovementStrategy,
ctx: MoveContext,
device: Device,
log: Logger,
config: Config,
): Promise<SweepOutcome> {
const { start, width, height } = ctx;
log.event(`Simulating activity (${strategy.name}) at ${timestamp()}...`);
for (const target of strategy.path(ctx)) {
const point: Point | null = resolveTarget(strategy.bounds, target, width, height);
if (point === null) {
log.event(`Out of bounds at ${timestamp()}; aborting simulation.`);
return "aborted";
}
await device.setPosition(point);
await device.sleep(config.stepDelay);
const current: Point = await device.getPosition();
if (
Math.abs(current.x - point.x) > READBACK_TOLERANCE ||
Math.abs(current.y - point.y) > READBACK_TOLERANCE
) {
// Cursor isn't where we last put it -> real user activity. Abort
// without snapping back, so we don't yank it from under the user.
//
// The comparison allows a small tolerance rather than demanding an
// exact match: on scaled (fractional-DPI) or multi-monitor setups
// the OS can place the cursor a pixel off the coordinate we
// commanded, and the edge-seeking patterns (clamp/reflect/arc)
// reach exactly the coordinates where that's most likely. A real
// user moves far more than a couple of pixels, so this doesn't
// meaningfully weaken real-user-wins.
log.event(`User activity detected at ${timestamp()}; aborting simulation.`);
return "interrupted";
}
}
await device.setPosition({ x: Math.round(start.x), y: Math.round(start.y) });
log.event("Mouse moved.");
return "completed";
}
+56 -120
View File
@@ -1,63 +1,38 @@
/** /**
* keeper.ts * keeper.ts
* --------- * ---------
* The actual "Teams Status Keeper" behavior: synthetic mouse activity with * The "Teams Status Keeper" behavior: the idle-watch loop plus the
* real-user-wins semantics, plus the idle-watch loop that drives it. * per-sweep glue that ties a movement strategy to the execution driver.
* *
* Runtime: Bun (uses `@nut-tree-fork/nut-js` for cross-platform mouse + * The mechanics are split across three seams so this file stays small and
* screen). The nut.js auto-delay is disabled inside `runKeeper`, not at * the interesting parts stay testable:
* module load, so importing this module is side-effect-free. * - `device.ts` — the nut.js I/O boundary (injected here).
* - `strategies.ts` — pure "where to move" pattern generators.
* - `executor.ts` — the "how to move" driver (bounds, timing,
* interrupt detection, restore).
*
* `runKeeper` takes an optional `Device` so tests can drive the loop with a
* fake; production supplies the nut.js device. Importing this module is
* side-effect-free: nut.js isn't touched until `createNutDevice()` runs.
* *
* Logging policy: * Logging policy:
* - The startup banner in `runKeeper` is unconditional so the user always * - The startup banner in `runKeeper` is unconditional so the user always
* sees confirmation that the process is alive. * sees the process is alive.
* - Every per-sweep / interrupt / bounds log is gated by `config.verbose` * - Per-sweep / interrupt / bounds lines are gated by `config.verbose`
* so the default is quiet. Errors stay on `console.error` (unconditional, * (see `makeLogger`). Errors stay on `console.error`, raised by the
* raised by the entry point on unhandled rejection). * entry point on unhandled rejection.
*/ */
import { mouse, Point, screen } from "@nut-tree-fork/nut-js"; import { createNutDevice, type Device, type Point } from "./device.ts";
import { executePath, type Logger } from "./executor.ts";
import { DEFAULT_PATTERN, STRATEGIES, type MoveContext } from "./strategies.ts";
import type { Config } from "./config.ts"; import type { Config } from "./config.ts";
/**
* Promise-based `setTimeout` wrapper. Allows `await sleep(ms)` ergonomics.
*
* @param ms - Duration to wait, in milliseconds.
*/
const sleep = (ms: number): Promise<void> =>
new Promise<void>((resolve: () => void): void => {
setTimeout(resolve, ms);
});
/**
* Format the current local time as `HH:MM:SS` (24-hour, zero-padded).
* Used for human-readable log lines. Date is intentionally omitted.
*/
const timestamp = (): string => {
const d: Date = new Date();
const pad = (n: number): string => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
/**
* Minimal log surface used by `simulateActivity` and `runKeeper`. Named so
* it can appear directly in function signatures (clearer than
* `ReturnType<typeof makeLogger>`) and so a test could substitute a fake
* implementation if needed.
*
* - `info(msg)` prints unconditionally.
* - `event(msg)` prints only when `--verbose` / `verbose: true` is set.
*/
interface Logger {
info(msg: string): void;
event(msg: string): void;
}
/** /**
* Build a verbose-gated `Logger`. `info` is unconditional; `event` only * Build a verbose-gated `Logger`. `info` is unconditional; `event` only
* fires when the caller asked for verbose output. Returning a small object * fires when the caller asked for verbose output. Returning a small object
* keeps `simulateActivity` free of `if (verbose)` noise at every log site. * keeps call sites free of `if (verbose)` noise at every log line.
*/ */
function makeLogger(verbose: boolean): Logger { function makeLogger(verbose: boolean): Logger {
return { return {
@@ -73,61 +48,22 @@ function makeLogger(verbose: boolean): Logger {
/** /**
* Perform a single synthetic mouse-activity sweep. * Perform a single synthetic mouse-activity sweep.
* *
* Behavior: * Snapshots the cursor and screen (re-read every call so monitor changes
* 1. Snapshot the starting cursor position. * are handled), selects the configured strategy from the registry, and
* 2. Read current screen dimensions (re-read every call so monitor changes * hands the resulting path to `executePath`, which owns bounds, pacing,
* are handled correctly). * interrupt detection, and restore-on-clean. An unknown `config.pattern`
* 3. Pick a horizontal direction (`dx`) that keeps the sweep on-screen: * falls back to the default strategy defensively; validation at the CLI /
* move right if there's room, otherwise move left. Vertical movement is * config-file boundary should prevent that from ever happening.
* currently disabled (`dy = 0`) but the framework is in place for
* richer patterns later.
* 4. For each of `config.stepCount` steps:
* - Compute the next target position.
* - Defensive bounds check (belt-and-braces given the `dx` choice).
* - Command nut.js to move the cursor there.
* - Sleep `config.stepDelay` — also the user's interrupt window.
* - Re-read the cursor. If it isn't where we put it, the user
* touched the mouse: log (verbose) and return early, leaving the
* cursor wherever the user moved it.
* 5. On a clean full sweep, restore the cursor to the starting position
* so the next idle-check sees "no movement" and doesn't misread the
* synthetic activity as the user returning.
*/ */
async function simulateActivity(config: Config, log: Logger): Promise<void> { async function simulateActivity(config: Config, log: Logger, device: Device): Promise<void> {
const start: Point = await mouse.getPosition(); const start: Point = await device.getPosition();
const screenWidth: number = await screen.width(); const width: number = await device.width();
const screenHeight: number = await screen.height(); const height: number = await device.height();
const dx: number = start.x + config.stepCount < screenWidth ? 1 : -1;
const dy: number = 0;
log.event(`Simulating activity at ${timestamp()}...`); const strategy = STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!;
const ctx: MoveContext = { start, width, height, rng: Math.random };
for (let i: number = 1; i <= config.stepCount; i++) { await executePath(strategy, ctx, device, log, config);
const expected: Point = new Point(start.x + i * dx, start.y + i * dy);
if (expected.x < 0 || expected.x >= screenWidth || expected.y < 0 || expected.y >= screenHeight) {
// Safety net for future non-linear movement patterns. With the
// current straight-line sweep + `dx` selection above, this branch
// should never fire.
log.event(`Out of bounds at ${timestamp()}; aborting simulation.`);
return;
}
await mouse.setPosition(expected);
await sleep(config.stepDelay);
const current: Point = await mouse.getPosition();
if (current.x !== expected.x || current.y !== expected.y) {
// Cursor isn't where we put it -> real user activity. Abort
// without snapping back, so we don't yank the cursor out from
// under the user.
log.event(`User activity detected at ${timestamp()}; aborting simulation.`);
return;
}
}
await mouse.setPosition(start);
log.event("Mouse moved.");
} }
/** /**
@@ -145,32 +81,26 @@ async function simulateActivity(config: Config, log: Logger): Promise<void> {
* idleness clock so we wait another full `moveInterval` before * idleness clock so we wait another full `moveInterval` before
* firing again. * firing again.
* *
* `simulateActivity` is designed so that its own synthetic movement never * `simulateActivity` (via `executePath`) is designed so its own synthetic
* counts as real activity: on a clean sweep it restores the cursor (so the * movement never counts as real activity: on a clean sweep it restores the
* next position check matches), and on a user-interrupted sweep the next * cursor, and on a user-interrupted sweep the next iteration sees the
* iteration sees the user's new position and correctly resets the clock. * user's new position and correctly resets the clock.
*
* @param config - Resolved runtime config.
* @param device - I/O device; defaults to the production nut.js device.
*/ */
export async function runKeeper(config: Config): Promise<void> { export async function runKeeper(config: Config, device?: Device): Promise<void> {
// nut.js inserts a configurable delay after every action (default 100ms). const dev: Device = device ?? (await createNutDevice());
// That default would silently more-than-double the duration of every
// setPosition and getPosition call. We drive cadence ourselves via
// config.stepDelay, so disable nut.js's implicit delay entirely.
//
// Setting this here (rather than at module load) keeps `keeper.ts` free
// of import-time side effects on the shared nut.js singleton — useful
// for tests and any future code path that imports this module without
// actually running the loop.
mouse.config.autoDelayMs = 0;
const log = makeLogger(config.verbose); const log = makeLogger(config.verbose);
log.info("Teams Status Keeper started. Press Ctrl+C to stop."); log.info("Teams Status Keeper started. Press Ctrl+C to stop.");
let lastPos: Point = await mouse.getPosition(); let lastPos: Point = await dev.getPosition();
let lastActivity: number = Date.now(); let lastActivity: number = Date.now();
while (true) { while (true) {
await sleep(config.checkInterval); await dev.sleep(config.checkInterval);
const pos: Point = await mouse.getPosition(); const pos: Point = await dev.getPosition();
const now: number = Date.now(); const now: number = Date.now();
if (pos.x !== lastPos.x || pos.y !== lastPos.y) { if (pos.x !== lastPos.x || pos.y !== lastPos.y) {
@@ -181,12 +111,18 @@ export async function runKeeper(config: Config): Promise<void> {
} }
if (now - lastActivity >= config.moveInterval) { if (now - lastActivity >= config.moveInterval) {
await simulateActivity(config, log); await simulateActivity(config, log, dev);
// `simulateActivity` either returns the cursor to its start // The sweep either restored the cursor to its start (clean) or
// (clean sweep) or leaves it where the user moved it (interrupt). // left it where the user moved it (interrupt). Either way, reset
// Either way we reset the clock and require another full // the clock and require another full moveInterval of inactivity
// moveInterval of inactivity before firing again. // before firing again.
lastActivity = Date.now(); lastActivity = Date.now();
// Re-sync lastPos to where the cursor actually ended. After a
// clean sweep this is a no-op (it was restored to start). After
// an interrupt it snaps lastPos to the user's position, so the
// next poll doesn't re-read that same displacement and count it a
// second time as fresh activity.
lastPos = await dev.getPosition();
} }
} }
} }
+39 -6
View File
@@ -12,13 +12,17 @@
* *
* Order of operations: * Order of operations:
* 1. Parse CLI args. Bad input -> stderr + usage hint, exit 2. * 1. Parse CLI args. Bad input -> stderr + usage hint, exit 2.
* 2. `--help` / `--version` short-circuit before any I/O or mouse work. * 2. `--help` / `--version` short-circuit before any I/O, config load, or
* mouse work. `keeper.ts` is also lazy-imported (see below) so these
* flags don't pay the cost of loading the nut.js native binary.
* 3. Load + validate the config file (default XDG path, or `--config * 3. Load + validate the config file (default XDG path, or `--config
* <path>` if supplied). Validation failures share the exit-2 path. * <path>` if supplied). Validation failures share the exit-2 path.
* 4. Resolve the full `Config` (CLI > file > DEFAULT_CONFIG) — verbose * 4. Resolve the full `Config` (CLI > file > DEFAULT_CONFIG) — verbose
* lives inside `Config` and is layered with the same precedence as * lives inside `Config` and is layered with the same precedence as
* the numeric fields. * the numeric fields.
* 5. Run keeper. Any unhandled rejection exits 1. * 5. Lazy-import `keeper.ts` (dynamic import keeps nut.js out of the
* `--help` / `--version` startup path) and run it. Any unhandled
* rejection — from the import itself or from the loop — exits 1.
* *
* Runtime: Bun (uses `@nut-tree-fork/nut-js` via `keeper.ts`). The shebang * Runtime: Bun (uses `@nut-tree-fork/nut-js` via `keeper.ts`). The shebang
* above lets this file run as a real CLI once linked via `bun link`. * above lets this file run as a real CLI once linked via `bun link`.
@@ -26,10 +30,17 @@
import { parseCliArgs, printHelp, VERSION } from "./cli.ts"; import { parseCliArgs, printHelp, VERSION } from "./cli.ts";
import type { ParsedCliArgs } from "./cli.ts"; import type { ParsedCliArgs } from "./cli.ts";
import { resolveConfig } from "./config.ts"; import { defaultConfigPath, resolveConfig } from "./config.ts";
import type { ConfigOverrides } from "./config.ts"; import type { ConfigOverrides } from "./config.ts";
import { loadConfigFile } from "./configFile.ts"; import { loadConfigFile } from "./configFile.ts";
import { runKeeper } from "./keeper.ts"; import { editConfig } from "./editor.ts";
// `keeper.ts` is intentionally NOT statically imported here. It transitively
// pulls in `@nut-tree-fork/nut-js`, which in turn dlopens a sizeable native
// `.node` binary. On a cold first run that load dominates startup (~1 s on
// macOS). For `--help` and `--version` we never actually need nut.js, so we
// defer the import to the only branch that actually runs the keeper loop.
// See the dynamic `await import("./keeper.ts")` near the bottom of the file.
/** /**
* Print a user-error message and exit 2. Used for anything that comes from * Print a user-error message and exit 2. Used for anything that comes from
@@ -81,6 +92,18 @@ if (cliArgs.version) {
process.exit(0); process.exit(0);
} }
if (cliArgs.edit) {
// Edit is a fully terminal action: open the config file in $EDITOR and
// hand the user's terminal over. Path resolution mirrors loadConfigFile's:
// honor `--config <path>` if set, else use the XDG default.
try {
const path: string = cliArgs.config ?? defaultConfigPath();
editConfig(path);
} catch (err: unknown) {
failUser(err);
}
}
let fileOverrides: ConfigOverrides | null; let fileOverrides: ConfigOverrides | null;
try { try {
fileOverrides = loadConfigFile(cliArgs.config); fileOverrides = loadConfigFile(cliArgs.config);
@@ -92,10 +115,20 @@ const cliOverrides: ConfigOverrides = {
moveInterval: cliArgs.moveInterval, moveInterval: cliArgs.moveInterval,
checkInterval: cliArgs.checkInterval, checkInterval: cliArgs.checkInterval,
stepDelay: cliArgs.stepDelay, stepDelay: cliArgs.stepDelay,
stepCount: cliArgs.stepCount, pattern: cliArgs.pattern,
verbose: cliArgs.verbose, verbose: cliArgs.verbose,
}; };
const config = resolveConfig(fileOverrides, cliOverrides); const config = resolveConfig(fileOverrides, cliOverrides);
runKeeper(config).catch(failRuntime); // Dynamic import so nut.js and the rest of the keeper machinery aren't
// loaded for invocations that exit early (--help, --version, validation
// failures). The `try` covers import-time failures too (e.g., a missing
// nut.js native binary), routing them through the same runtime-failure
// path as anything raised by the loop itself.
try {
const { runKeeper } = await import("./keeper.ts");
runKeeper(config).catch(failRuntime);
} catch (err: unknown) {
failRuntime(err);
}
+300
View File
@@ -0,0 +1,300 @@
/**
* strategies.ts
* -------------
* The movement-pattern seam: pure generators of cursor targets.
*
* A `MovementStrategy` describes *where* the cursor should go, as an
* iterable of ideal `Point`s starting from the sweep's origin. It performs
* no I/O, no timing, and no interrupt handling — that all belongs to the
* executor (`executor.ts`). This split is what makes patterns trivial to
* add (write one pure generator) and trivial to test (feed a deterministic
* `rng`, assert the emitted points).
*
* Coordinates emitted here may be fractional; the executor rounds to whole
* pixels before commanding the cursor and applies the strategy's declared
* `BoundsPolicy` to keep everything on-screen.
*
* Each pattern owns its own geometry — how many steps it takes, how far it
* reaches, how tight its radius is — as module-private constants below. Those
* are properties of the pattern, not user preferences: a jitter is inherently
* small and twitchy, an arc inherently a broad curve. There is deliberately
* no user knob for sweep size or step count; the cadence (`stepDelay`) is the
* only tunable, and it lives in the executor, not here. As a result this
* module needs nothing from `Config` and imports only `Point`.
*/
import type { Point } from "./device.ts";
/**
* How the executor keeps a strategy's targets on-screen:
*
* - `abort` — stop the sweep the moment a target falls out of bounds.
* Used by `line`, whose direction is chosen so this never
* actually fires; preserves the original straight-line
* semantics exactly.
* - `clamp` — pin each out-of-bounds coordinate to the nearest edge.
* - `reflect` — mirror out-of-bounds coordinates back inside, so a roaming
* pattern bounces off the screen edges instead of sticking.
*/
export type BoundsPolicy = "abort" | "clamp" | "reflect";
/**
* Everything a strategy needs to generate a path. Screen dimensions and the
* start point are snapshotted per sweep by the caller; `rng` is injected so
* stochastic strategies are deterministic under test.
*/
export interface MoveContext {
/** Cursor position at the start of the sweep. */
readonly start: Point;
/** Primary-screen width in pixels. */
readonly width: number;
/** Primary-screen height in pixels. */
readonly height: number;
/** Uniform [0, 1) source. Defaults to `Math.random`; tests inject a fake. */
readonly rng: () => number;
}
/**
* A named movement pattern.
*
* - `name` — registry key, also the value accepted by `--pattern` / the
* `pattern` config key.
* - `bounds` — how the executor confines this pattern to the screen.
* - `path` — pure generator of ideal (possibly fractional) targets,
* emitted in visiting order. Should not re-emit `start`.
*/
export interface MovementStrategy {
readonly name: string;
readonly bounds: BoundsPolicy;
path(ctx: MoveContext): Iterable<Point>;
}
/** Clamp `v` into the inclusive pixel range `[0, max - 1]`. */
function clamp(v: number, max: number): number {
if (v < 0) return 0;
if (v > max - 1) return max - 1;
return v;
}
/**
* `line` — the original behavior, preserved exactly.
*
* Pick a horizontal direction that keeps the sweep on-screen (right if
* there's room, else left) and walk `LINE_STEPS` single-pixel steps with no
* vertical movement. 250 one-pixel steps is byte-for-byte the sweep the
* keeper produced before movement patterns existed, which is why its bounds
* policy is `abort` (the direction choice guarantees it never triggers).
*/
const LINE_STEPS = 250;
export const line: MovementStrategy = {
name: "line",
bounds: "abort",
*path(ctx: MoveContext): Generator<Point> {
const { start, width } = ctx;
const dx: number = start.x + LINE_STEPS < width ? 1 : -1;
for (let i = 1; i <= LINE_STEPS; i++) {
yield { x: start.x + i * dx, y: start.y };
}
},
};
/**
* `diagonal` — straight line on both axes at once. Each axis's direction is
* chosen independently by available room, so the sweep heads toward the
* roomiest corner and stays on-screen. 250 single-pixel steps per axis
* (≈250px reach), matching `line`'s magnitude.
*/
const DIAGONAL_STEPS = 250;
export const diagonal: MovementStrategy = {
name: "diagonal",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> {
const { start, width, height } = ctx;
const dx: number = start.x + DIAGONAL_STEPS < width ? 1 : -1;
const dy: number = start.y + DIAGONAL_STEPS < height ? 1 : -1;
for (let i = 1; i <= DIAGONAL_STEPS; i++) {
yield { x: start.x + i * dx, y: start.y + i * dy };
}
},
};
/**
* `jitter` — many small random hops within a tight radius of the start.
* Subtle "fidget" activity rather than a broad sweep. The radius is large
* enough that every hop is a real, distinct pixel move rather than rounding
* onto the pixel the cursor already occupies. The executor restores the
* cursor to `start` after a clean run, so the net displacement is zero.
*/
const JITTER_STEPS = 80;
const JITTER_RADIUS = 30;
export const jitter: MovementStrategy = {
name: "jitter",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> {
const { start, rng } = ctx;
for (let i = 1; i <= JITTER_STEPS; i++) {
const angle: number = rng() * 2 * Math.PI;
const r: number = rng() * JITTER_RADIUS;
yield { x: start.x + Math.cos(angle) * r, y: start.y + Math.sin(angle) * r };
}
},
};
/**
* `walk` — an unbounded cumulative random walk: each step adds a random
* per-axis delta in `[-WALK_STEP, +WALK_STEP]`. The per-step magnitude is
* deliberately several pixels so the walk actually roams — a ±1px walk over
* this many steps would drift only ~√N pixels net. The generator lets the
* position drift freely; the executor's `reflect` policy mirrors it back
* on-screen, so the cursor bounces off the edges instead of escaping.
*/
const WALK_STEPS = 200;
const WALK_STEP = 4;
export const walk: MovementStrategy = {
name: "walk",
bounds: "reflect",
*path(ctx: MoveContext): Generator<Point> {
const { start, rng } = ctx;
let x: number = start.x;
let y: number = start.y;
for (let i = 1; i <= WALK_STEPS; i++) {
x += (rng() * 2 - 1) * WALK_STEP;
y += (rng() * 2 - 1) * WALK_STEP;
yield { x, y };
}
},
};
/**
* `arc` — a smooth quadratic Bézier curve from the start to a random
* on-screen endpoint `ARC_REACH` pixels away, bowed out by a control point
* offset perpendicular to the straight path. `ARC_STEPS` samples keep the
* curve smooth. Produces natural, hand-like curved motion.
*/
const ARC_STEPS = 120;
const ARC_REACH = 300;
export const arc: MovementStrategy = {
name: "arc",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> {
const { start, width, height, rng } = ctx;
// Endpoint: a random direction, `ARC_REACH` away, clamped on-screen.
const angle: number = rng() * 2 * Math.PI;
const endX: number = clamp(start.x + Math.cos(angle) * ARC_REACH, width);
const endY: number = clamp(start.y + Math.sin(angle) * ARC_REACH, height);
// Control point: midpoint pushed along the perpendicular so the path
// bows rather than running straight. Direction/magnitude randomized.
const midX: number = (start.x + endX) / 2;
const midY: number = (start.y + endY) / 2;
const perpX: number = -(endY - start.y);
const perpY: number = endX - start.x;
const perpLen: number = Math.hypot(perpX, perpY) || 1;
const bow: number = (rng() * 2 - 1) * ARC_REACH * 0.5;
const ctrlX: number = clamp(midX + (perpX / perpLen) * bow, width);
const ctrlY: number = clamp(midY + (perpY / perpLen) * bow, height);
for (let i = 1; i <= ARC_STEPS; i++) {
const t: number = i / ARC_STEPS;
const u: number = 1 - t;
yield {
x: u * u * start.x + 2 * u * t * ctrlX + t * t * endX,
y: u * u * start.y + 2 * u * t * ctrlY + t * t * endY,
};
}
},
};
/**
* `figureEight` — traces a Gerono lemniscate (a figure-eight) around the
* start point over one full period, so it returns to the origin.
* `FIG8_AMP` sets its half-width (≈250px across); `FIG8_STEPS` samples keep
* the curve smooth.
*/
const FIG8_STEPS = 90;
const FIG8_AMP = 125;
export const figureEight: MovementStrategy = {
name: "figureEight",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> {
const { start } = ctx;
for (let i = 1; i <= FIG8_STEPS; i++) {
const t: number = (2 * Math.PI * i) / FIG8_STEPS;
yield {
x: start.x + FIG8_AMP * Math.sin(t),
y: start.y + FIG8_AMP * Math.sin(t) * Math.cos(t),
};
}
},
};
/**
* The registry of every selectable movement pattern, keyed by name. Adding
* a strategy is a one-line addition here plus its definition above.
*/
export const STRATEGIES: Readonly<Record<string, MovementStrategy>> = {
line,
diagonal,
jitter,
walk,
arc,
figureEight,
};
/** Pattern used when neither the CLI nor the config file selects one. */
export const DEFAULT_PATTERN = "line";
/** All valid pattern names, for validation messages and help text. */
export const PATTERN_NAMES: readonly string[] = Object.keys(STRATEGIES);
/**
* The set of valid `--pattern` / `pattern` values as a string-literal-ish
* type. Kept as `string` at the type level (the registry is the runtime
* source of truth); `isPatternName` is the guard callers use.
*/
export type PatternName = string;
/** True when `name` is an exact, registered strategy key. */
export function isPatternName(name: string): boolean {
return Object.prototype.hasOwnProperty.call(STRATEGIES, name);
}
/**
* Normalize a pattern name for lenient user-facing matching: lowercase and
* strip separators (`-`, `_`, whitespace) so `figure-eight`, `figure_eight`,
* and `FIGUREEIGHT` all collapse onto the same key as `figureEight`.
*/
const normalizePattern = (s: string): string => s.toLowerCase().replace(/[-_\s]/g, "");
/**
* Map of normalized name -> canonical registry key. Built once at module
* load. The assertion below guards against two registered names collapsing
* to the same normalized form (e.g. a future `"figure_eight"` alongside
* `"figureEight"`), which would otherwise let one silently shadow the other.
*/
const CANONICAL_PATTERNS: ReadonlyMap<string, string> = new Map(
PATTERN_NAMES.map((n) => [normalizePattern(n), n]),
);
if (CANONICAL_PATTERNS.size !== PATTERN_NAMES.length) {
throw new Error(
"strategies.ts: two pattern names collide after normalization; rename one so they differ by more than case/separators",
);
}
/**
* Resolve loose user input to the canonical registry key, or `null` when no
* registered strategy matches. Used at the CLI and config-file validation
* boundaries so `Config.pattern` is always a canonical key and the keeper's
* direct `STRATEGIES[pattern]` lookup needs no normalization of its own.
*/
export function resolvePatternName(name: string): string | null {
return CANONICAL_PATTERNS.get(normalizePattern(name)) ?? null;
}
+9 -4
View File
@@ -15,7 +15,7 @@ const NONE: ConfigOverrides = {
moveInterval: undefined, moveInterval: undefined,
checkInterval: undefined, checkInterval: undefined,
stepDelay: undefined, stepDelay: undefined,
stepCount: undefined, pattern: undefined,
verbose: undefined, verbose: undefined,
}; };
@@ -44,11 +44,16 @@ describe("resolveConfig", () => {
expect(cfg.checkInterval).toBe(2000); expect(cfg.checkInterval).toBe(2000);
}); });
test("stepDelay and stepCount pass through untouched (no unit conversion)", () => { test("stepDelay passes through untouched (no unit conversion)", () => {
const cli: ConfigOverrides = { ...NONE, stepDelay: 75, stepCount: 100 }; const cli: ConfigOverrides = { ...NONE, stepDelay: 75 };
const cfg = resolveConfig(null, cli); const cfg = resolveConfig(null, cli);
expect(cfg.stepDelay).toBe(75); expect(cfg.stepDelay).toBe(75);
expect(cfg.stepCount).toBe(100); });
test("pattern: CLI wins over file, file wins over default", () => {
expect(resolveConfig({ ...NONE, pattern: "arc" }, { ...NONE, pattern: "walk" }).pattern).toBe("walk");
expect(resolveConfig({ ...NONE, pattern: "arc" }, NONE).pattern).toBe("arc");
expect(resolveConfig(null, NONE).pattern).toBe(DEFAULT_CONFIG.pattern);
}); });
test("verbose: CLI true wins over file false", () => { test("verbose: CLI true wins over file false", () => {
+40 -3
View File
@@ -43,7 +43,7 @@ describe("loadConfigFile (explicit path)", () => {
// Fields not in the file are undefined. // Fields not in the file are undefined.
expect(result!.checkInterval).toBeUndefined(); expect(result!.checkInterval).toBeUndefined();
expect(result!.stepDelay).toBeUndefined(); expect(result!.stepDelay).toBeUndefined();
expect(result!.stepCount).toBeUndefined(); expect(result!.pattern).toBeUndefined();
}); });
test("returns all-undefined overrides for an empty object", () => { test("returns all-undefined overrides for an empty object", () => {
@@ -81,8 +81,8 @@ describe("loadConfigFile (explicit path)", () => {
}); });
test("throws on non-positive numeric values", () => { test("throws on non-positive numeric values", () => {
const negative = writeFixture("neg.json", JSON.stringify({ stepCount: -1 })); const negative = writeFixture("neg.json", JSON.stringify({ moveInterval: -1 }));
expect(() => loadConfigFile(negative)).toThrow(/'stepCount'.*positive number/); expect(() => loadConfigFile(negative)).toThrow(/'moveInterval'.*positive number/);
const zero = writeFixture("zero.json", JSON.stringify({ stepDelay: 0 })); const zero = writeFixture("zero.json", JSON.stringify({ stepDelay: 0 }));
expect(() => loadConfigFile(zero)).toThrow(/'stepDelay'.*positive number/); expect(() => loadConfigFile(zero)).toThrow(/'stepDelay'.*positive number/);
@@ -97,6 +97,43 @@ describe("loadConfigFile (explicit path)", () => {
const path = writeFixture("verbose.json", JSON.stringify({ verbose: "yes" })); const path = writeFixture("verbose.json", JSON.stringify({ verbose: "yes" }));
expect(() => loadConfigFile(path)).toThrow(/'verbose'.*boolean/); expect(() => loadConfigFile(path)).toThrow(/'verbose'.*boolean/);
}); });
test("accepts a known pattern", () => {
const path = writeFixture("pattern.json", JSON.stringify({ pattern: "arc" }));
const result = loadConfigFile(path);
expect(result!.pattern).toBe("arc");
});
test("normalizes a loosely-spelled pattern to its canonical name", () => {
const path = writeFixture("loosepattern.json", JSON.stringify({ pattern: "figure-eight" }));
const result = loadConfigFile(path);
expect(result!.pattern).toBe("figureEight");
});
test("throws on an unknown pattern, listing the valid names", () => {
const path = writeFixture("badpattern.json", JSON.stringify({ pattern: "zigzag" }));
expect(() => loadConfigFile(path)).toThrow(/'pattern'.*valid:/);
expect(() => loadConfigFile(path)).toThrow(/line/);
});
test("tolerates obsolete stepCount/stepSize keys, ignoring their values", () => {
// Seeded by pre-1.3.0 installs; must not hard-fail on upgrade. They're
// accepted but not surfaced as overrides (and even an invalid value,
// like a negative, is ignored rather than rejected).
const path = writeFixture(
"obsolete.json",
JSON.stringify({ moveInterval: 60, stepCount: -1, stepSize: 3 }),
);
const result = loadConfigFile(path);
expect(result).not.toBeNull();
expect(result!.moveInterval).toBe(60);
expect(result as unknown as Record<string, unknown>).not.toHaveProperty("stepCount");
});
test("still rejects a genuinely unknown key", () => {
const path = writeFixture("unknown.json", JSON.stringify({ movInterval: 60 }));
expect(() => loadConfigFile(path)).toThrow(/unknown key 'movInterval'/);
});
}); });
describe("loadConfigFile (default path)", () => { describe("loadConfigFile (default path)", () => {
+104
View File
@@ -0,0 +1,104 @@
/**
* editor.test.ts
* --------------
* Unit tests for the `--edit` helper. The spawn path is not exercised
* (would actually launch $EDITOR); instead we test:
* - the pure argv-construction helper, and
* - the two refusal paths ($EDITOR unset, file missing).
*
* Run via `bun test`.
*/
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { editConfig, editorCommand } from "../src/editor.ts";
import { CliError } from "../src/errors.ts";
describe("editorCommand", () => {
test("builds 'sh -c <editor> \"$@\"' argv with -- placeholder and path", () => {
const argv = editorCommand("vim", "/tmp/x.json");
expect(argv).toEqual(["sh", "-c", 'vim "$@"', "--", "/tmp/x.json"]);
});
test("interpolates the editor verbatim so shell word-splits multi-word values", () => {
const argv = editorCommand("code --wait", "/path with space.json");
expect(argv).toEqual([
"sh",
"-c",
'code --wait "$@"',
"--",
"/path with space.json",
]);
});
});
describe("editConfig", () => {
let TMP: string;
let savedEditor: string | undefined;
beforeAll(() => {
TMP = mkdtempSync(join(tmpdir(), "move-edit-test-"));
});
afterAll(() => {
rmSync(TMP, { recursive: true, force: true });
});
beforeEach(() => {
savedEditor = process.env.EDITOR;
});
afterEach(() => {
if (savedEditor === undefined) delete process.env.EDITOR;
else process.env.EDITOR = savedEditor;
});
test("throws CliError when $EDITOR is unset", () => {
delete process.env.EDITOR;
expect(() => editConfig(join(TMP, "any.json"))).toThrow(CliError);
});
test("throws CliError when $EDITOR is empty", () => {
process.env.EDITOR = "";
expect(() => editConfig(join(TMP, "any.json"))).toThrow(CliError);
});
test("throws CliError when the config file does not exist", () => {
// Use a benign editor command that we never actually reach (the
// existence check fires first).
process.env.EDITOR = "true";
const missing = join(TMP, "no-such-file.json");
expect(() => editConfig(missing)).toThrow(/no config file at/);
});
test("error message names the missing path", () => {
process.env.EDITOR = "true";
const missing = join(TMP, "missing.json");
expect(() => editConfig(missing)).toThrow(new RegExp(missing.replace(/[.]/g, "\\.")));
});
test("error message mentions 'reinstall' as a recovery hint", () => {
process.env.EDITOR = "true";
expect(() => editConfig(join(TMP, "x.json"))).toThrow(/reinstall/);
});
test("$EDITOR unset error explicitly mentions setting it", () => {
delete process.env.EDITOR;
expect(() => editConfig(join(TMP, "x.json"))).toThrow(/export EDITOR/);
});
// Success path: $EDITOR set, file exists. The editor IS spawned and we
// then call process.exit() — which kills the test process. So we don't
// exercise this code path in unit tests; the manual smoke test in
// dev-setup verifies end-to-end behavior instead.
test("placeholder: success path is verified via manual `EDITOR=true move -e` run", () => {
// Intentionally empty assertion. See comment above.
expect(true).toBe(true);
// Ensure the fixture path is referenced so this test isn't seen
// as truly empty if the fixture system ever needs assertion.
writeFileSync(join(TMP, "exists.json"), "{}");
});
});
+187
View File
@@ -0,0 +1,187 @@
/**
* executor.test.ts
* ----------------
* Unit tests for the execution driver against a fake `Device`. Covers the
* three sweep outcomes, all three bounds policies, the rounding/interrupt
* contract, and step pacing — none of which was testable before the device
* seam existed.
*/
import { describe, expect, test } from "bun:test";
import { DEFAULT_CONFIG } from "../src/config.ts";
import type { Config } from "../src/config.ts";
import type { Device, Point } from "../src/device.ts";
import { executePath, type Logger } from "../src/executor.ts";
import type { BoundsPolicy, MoveContext, MovementStrategy } from "../src/strategies.ts";
const noopLog: Logger = { info: (): void => {}, event: (): void => {} };
/**
* A scriptable `Device`. `getPosition` echoes the last commanded point
* (simulating "the cursor stayed where we put it") unless `overrides` maps
* the current getPosition call index to a substitute — used to inject a
* mid-sweep user interruption.
*/
class FakeDevice implements Device {
commanded: Point[] = [];
sleeps: number[] = [];
getCalls = 0;
overrides = new Map<number, Point>();
constructor(public w = 1920, public h = 1080, public initial: Point = { x: 0, y: 0 }) {}
async getPosition(): Promise<Point> {
this.getCalls++;
const o = this.overrides.get(this.getCalls);
if (o) return o;
return this.commanded.at(-1) ?? this.initial;
}
async setPosition(p: Point): Promise<void> {
this.commanded.push(p);
}
async width(): Promise<number> {
return this.w;
}
async height(): Promise<number> {
return this.h;
}
async sleep(ms: number): Promise<void> {
this.sleeps.push(ms);
}
}
/** A strategy that emits a fixed list of points under a chosen bounds policy. */
function fixed(points: Point[], bounds: BoundsPolicy): MovementStrategy {
return {
name: "fixed",
bounds,
*path(): Generator<Point> {
yield* points;
},
};
}
function ctxOf(start: Point, width: number, height: number): MoveContext {
return { start, width, height, rng: Math.random };
}
/** A full `Config` for the executor's pacing; only `stepDelay` matters here. */
function cfgOf(config?: Partial<Config>): Config {
return { ...DEFAULT_CONFIG, ...config };
}
describe("executePath — outcomes", () => {
test("clean sweep commands every point, restores to start, returns 'completed'", async () => {
const dev = new FakeDevice();
const start = { x: 500, y: 500 };
const pts = [
{ x: 501, y: 500 },
{ x: 502, y: 500 },
{ x: 503, y: 500 },
];
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("completed");
// 3 steps + 1 restore.
expect(dev.commanded).toEqual([...pts, start]);
});
test("interruption mid-sweep returns 'interrupted' and does NOT restore", async () => {
const dev = new FakeDevice();
const start = { x: 500, y: 500 };
const pts = [
{ x: 501, y: 500 },
{ x: 502, y: 500 },
{ x: 503, y: 500 },
];
// 2nd getPosition call reports the user elsewhere.
dev.overrides.set(2, { x: 9, y: 9 });
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("interrupted");
// Commanded points 1 and 2 only; never restored to start.
expect(dev.commanded).toEqual([pts[0]!, pts[1]!]);
expect(dev.commanded.at(-1)).not.toEqual(start);
});
test("abort policy stops before commanding an out-of-bounds point", async () => {
const dev = new FakeDevice(100, 100);
const pts = [{ x: 150, y: 10 }]; // x >= width
const outcome = await executePath(fixed(pts, "abort"), ctxOf({ x: 10, y: 10 }, 100, 100), dev, noopLog, cfgOf());
expect(outcome).toBe("aborted");
expect(dev.commanded).toEqual([]);
});
});
describe("executePath — bounds policies", () => {
test("clamp pins out-of-bounds coordinates to the inset edges", async () => {
const dev = new FakeDevice(100, 100);
const pts = [
{ x: -5, y: 50 },
{ x: 9999, y: 50 },
];
// travelRange(100) is inset by EDGE_MARGIN (2) to [2, 97].
await executePath(fixed(pts, "clamp"), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf());
expect(dev.commanded[0]).toEqual({ x: 2, y: 50 });
expect(dev.commanded[1]).toEqual({ x: 97, y: 50 });
});
test("reflect mirrors out-of-bounds coordinates back inside the inset range", async () => {
const dev = new FakeDevice(100, 100);
// Inset range [2, 97], span = 95; x=120 -> (120-2)=118, 190-118=72, +2 = 74.
const pts = [{ x: 120, y: 50 }];
await executePath(fixed(pts, "reflect"), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf());
expect(dev.commanded[0]).toEqual({ x: 74, y: 50 });
});
});
describe("executePath — readback tolerance", () => {
test("a readback within tolerance is not treated as interruption", async () => {
const dev = new FakeDevice();
const start = { x: 500, y: 500 };
const pts = [
{ x: 510, y: 500 },
{ x: 520, y: 500 },
];
// Each in-sweep readback lands 2px off the commanded point (OS jitter,
// not the user). 2px is within READBACK_TOLERANCE, so the sweep runs on.
dev.overrides.set(1, { x: 512, y: 501 });
dev.overrides.set(2, { x: 518, y: 499 });
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("completed");
expect(dev.commanded).toEqual([...pts, start]);
});
test("a readback beyond tolerance is treated as interruption", async () => {
const dev = new FakeDevice();
const start = { x: 500, y: 500 };
const pts = [
{ x: 510, y: 500 },
{ x: 520, y: 500 },
];
// First readback is 3px off -> exceeds the 2px tolerance -> real user.
dev.overrides.set(1, { x: 513, y: 500 });
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("interrupted");
expect(dev.commanded).toEqual([pts[0]!]);
});
});
describe("executePath — rounding & pacing", () => {
test("fractional targets are rounded and do not read as interruption", async () => {
const dev = new FakeDevice();
const start = { x: 500, y: 500 };
const pts = [{ x: 10.4, y: 20.6 }]; // -> (10, 21)
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("completed");
expect(dev.commanded[0]).toEqual({ x: 10, y: 21 });
});
test("sleeps once per step with the configured stepDelay", async () => {
const dev = new FakeDevice();
const pts = [
{ x: 501, y: 500 },
{ x: 502, y: 500 },
];
await executePath(fixed(pts, "clamp"), ctxOf({ x: 500, y: 500 }, dev.w, dev.h), dev, noopLog, cfgOf({ stepDelay: 7 }));
expect(dev.sleeps).toEqual([7, 7]);
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* keeper.test.ts
* --------------
* Loop-level tests for `runKeeper` driven by a fake `Device`. The loop runs
* forever in production, so the fake stops it by throwing a sentinel from
* `sleep` once a call budget is exhausted; the test then inspects the
* commands that were issued.
*
* These assert the two behaviors that matter: an idle cursor triggers a
* synthetic sweep, and a moving cursor never does.
*/
import { describe, expect, test } from "bun:test";
import { DEFAULT_CONFIG } from "../src/config.ts";
import type { Config } from "../src/config.ts";
import type { Device, Point } from "../src/device.ts";
import { runKeeper } from "../src/keeper.ts";
class StopError extends Error {}
/**
* Fake device that echoes the last commanded point (so a synthetic sweep
* completes cleanly) and aborts the loop after `budget` sleeps.
*
* `positions`, when provided, is consumed one entry per `getPosition` call
* to simulate real user movement; otherwise the cursor is reported as
* stationary at `initial`/the last commanded point (idle).
*/
class LoopDevice implements Device {
commanded: Point[] = [];
sleepCount = 0;
constructor(
public budget: number,
public initial: Point = { x: 100, y: 100 },
private positions: Point[] | null = null,
) {}
async getPosition(): Promise<Point> {
if (this.positions) return this.positions.shift() ?? this.initial;
return this.commanded.at(-1) ?? this.initial;
}
async setPosition(p: Point): Promise<void> {
this.commanded.push(p);
}
async width(): Promise<number> {
return 1920;
}
async height(): Promise<number> {
return 1080;
}
async sleep(): Promise<void> {
if (++this.sleepCount > this.budget) throw new StopError();
}
}
const quietConfig = (overrides: Partial<Config>): Config => ({
...DEFAULT_CONFIG,
verbose: false,
...overrides,
});
async function runUntilStop(config: Config, device: Device): Promise<void> {
try {
await runKeeper(config, device);
} catch (err) {
if (!(err instanceof StopError)) throw err;
}
}
describe("runKeeper", () => {
test("fires a synthetic sweep once the cursor has been idle long enough", async () => {
// moveInterval 0 => any elapsed time counts as "idle long enough",
// so the first idle check triggers a sweep deterministically.
const dev = new LoopDevice(50);
await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line" }), dev);
// A sweep issued setPosition commands (the sweep is interrupted by the
// sleep budget before it finishes, but many steps land); an idle loop
// with no sweep would have issued none.
expect(dev.commanded.length).toBeGreaterThanOrEqual(3);
});
test("does not fire while the cursor keeps moving", async () => {
// Every poll reports a new position => always "real activity", so the
// idleness clock keeps resetting and no sweep ever fires.
const moving: Point[] = Array.from({ length: 40 }, (_, i) => ({ x: i, y: i }));
const dev = new LoopDevice(20, { x: 0, y: 0 }, moving);
await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line" }), dev);
expect(dev.commanded.length).toBe(0);
});
});
+161
View File
@@ -0,0 +1,161 @@
/**
* strategies.test.ts
* ------------------
* Unit tests for the pure movement-pattern generators. No nut.js, no
* device: each strategy is exercised by feeding a `MoveContext` (with a
* deterministic `rng` where randomness matters) and asserting on the
* emitted points.
*/
import { describe, expect, test } from "bun:test";
import type { Point } from "../src/device.ts";
import {
arc,
diagonal,
figureEight,
isPatternName,
jitter,
line,
PATTERN_NAMES,
resolvePatternName,
STRATEGIES,
walk,
type MoveContext,
} from "../src/strategies.ts";
/** Deterministic PRNG so stochastic strategies are reproducible under test. */
function mulberry32(seed: number): () => number {
let a = seed;
return (): number => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function ctxOf(overrides: {
start?: Point;
width?: number;
height?: number;
rng?: () => number;
}): MoveContext {
return {
start: overrides.start ?? { x: 500, y: 500 },
width: overrides.width ?? 1920,
height: overrides.height ?? 1080,
rng: overrides.rng ?? Math.random,
};
}
describe("line", () => {
test("emits its full 250-step, 250px sweep along +x with no vertical drift (preserved default)", () => {
const pts = [...line.path(ctxOf({ start: { x: 500, y: 500 } }))];
expect(pts.length).toBe(250);
expect(pts.every((p) => p.y === 500)).toBe(true);
// 1px per step: 501..750.
expect(pts[0]!.x).toBe(501);
expect(pts.at(-1)!.x).toBe(750);
});
test("reverses direction when there is no room to the right", () => {
const pts = [...line.path(ctxOf({ start: { x: 90, y: 10 }, width: 100 }))];
expect(pts[0]!.x).toBe(89);
// Heads left: each step decreases x by 1.
expect(pts[1]!.x).toBe(88);
expect(pts.at(-1)!.x).toBe(90 - 250);
});
});
describe("diagonal", () => {
test("moves 1px on both axes toward the roomy corner for 250 steps", () => {
const pts = [...diagonal.path(ctxOf({ start: { x: 500, y: 500 } }))];
expect(pts.length).toBe(250);
expect(pts[0]!).toEqual({ x: 501, y: 501 });
expect(pts.at(-1)!).toEqual({ x: 750, y: 750 });
});
});
describe("jitter", () => {
test("stays within its fixed radius of start across its fixed step count", () => {
const radius = 30; // JITTER_RADIUS
const start = { x: 500, y: 500 };
const pts = [...jitter.path(ctxOf({ start, rng: mulberry32(1) }))];
expect(pts.length).toBe(80); // JITTER_STEPS
for (const p of pts) {
expect(Math.hypot(p.x - start.x, p.y - start.y)).toBeLessThanOrEqual(radius + 1e-9);
}
});
});
describe("walk", () => {
test("is a cumulative walk; a 0.5-constant rng yields zero net drift", () => {
const start = { x: 400, y: 300 };
const pts = [...walk.path(ctxOf({ start, rng: () => 0.5 }))];
expect(pts.length).toBe(200); // WALK_STEPS
// (0.5*2 - 1) === 0, so every step delta is zero.
expect(pts.every((p) => p.x === start.x && p.y === start.y)).toBe(true);
});
test("accumulates finite deltas step over step", () => {
const pts = [...walk.path(ctxOf({ rng: mulberry32(42) }))];
expect(pts.length).toBe(200);
expect(pts.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))).toBe(true);
});
});
describe("arc", () => {
test("emits its fixed step count of finite points, deterministic under a fixed seed", () => {
const pts = [...arc.path(ctxOf({ rng: mulberry32(7) }))];
expect(pts.length).toBe(120); // ARC_STEPS
expect(pts.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))).toBe(true);
// Same seed -> same endpoint (t = 1 at the final step is a stable point).
const again = [...arc.path(ctxOf({ rng: mulberry32(7) }))];
expect(pts.at(-1)).toEqual(again.at(-1)!);
});
});
describe("figureEight", () => {
test("returns to the start point after one full period", () => {
const start = { x: 600, y: 400 };
const pts = [...figureEight.path(ctxOf({ start }))];
expect(pts.length).toBe(90); // FIG8_STEPS
expect(pts.at(-1)!.x).toBeCloseTo(start.x, 6);
expect(pts.at(-1)!.y).toBeCloseTo(start.y, 6);
});
});
describe("registry", () => {
test("PATTERN_NAMES matches the registry keys and includes the default", () => {
expect(new Set(PATTERN_NAMES)).toEqual(new Set(Object.keys(STRATEGIES)));
expect(PATTERN_NAMES).toContain("line");
});
test("isPatternName accepts registered names and rejects others", () => {
for (const name of PATTERN_NAMES) expect(isPatternName(name)).toBe(true);
expect(isPatternName("zigzag")).toBe(false);
expect(isPatternName("")).toBe(false);
// Must not be fooled by inherited Object.prototype members.
expect(isPatternName("toString")).toBe(false);
});
test("resolvePatternName maps every canonical name to itself", () => {
for (const name of PATTERN_NAMES) expect(resolvePatternName(name)).toBe(name);
});
test("resolvePatternName normalizes case and separators", () => {
expect(resolvePatternName("figure-eight")).toBe("figureEight");
expect(resolvePatternName("figure_eight")).toBe("figureEight");
expect(resolvePatternName("FIGUREEIGHT")).toBe("figureEight");
expect(resolvePatternName(" Figure Eight ")).toBe("figureEight");
expect(resolvePatternName("LINE")).toBe("line");
});
test("resolvePatternName returns null for unknown or prototype names", () => {
expect(resolvePatternName("zigzag")).toBeNull();
expect(resolvePatternName("")).toBeNull();
expect(resolvePatternName("toString")).toBeNull();
});
});