Files
StepForge/tests/unit/capture-fixes.test.js
TylerandClaude Fable 5 f62e3e19cb
Template tests / tests (pull_request) Failing after 33s
Fix region capture, power ownership, click-source reporting, strict timing
Phase 2 of the improvement plan (PR 5 of the sequence). Several confirmed
capture defects from the audit.

Region capture:
- regionCapture returned { ok, step } wrapping storeFrameAsStep's own
  { ok, step }, so the real step was at result.step.step — region selection
  and region auto-doc (which read result.step.stepId) broke. Return the
  storeFrameAsStep result directly.
- pickRegion leaked the region:picked IPC listener (and the overlay/image
  refs) whenever the overlay was cancelled/closed rather than picked: cleanup
  only ran on a pick. Cleanup is now idempotent and runs on pick, close, load
  failure, and settle. The received rectangle is validated and clamped to the
  image (new overlayRectToImageRect handles negative-size drags and
  out-of-bounds selections) so image.crop can never read out of bounds.

Power blocker ownership:
- New single owner: CaptureService.syncPower() holds the blocker iff a session
  is actively recording, called on start/pause/resume/finish. A new session
  starts paused and no longer holds the blocker while idle; tray and
  second-instance pauses that previously bypassed main.js's stop closure now
  release it correctly. main.js provides the power policy (blocker + EcoQoS
  opt-out) via dependency injection.

Explicit click-trigger source:
- startEvdevWatcher never set clickWatcher, so state().clickCapture read false
  while evdev was actively capturing clicks. Replaced the boolean with an
  explicit clickSource (windows-hook | x11 | evdev-x11 | evdev-wayland |
  unavailable); clickCapture is now derived from it. evdev device-stream
  errors/closes now fall back via handleClickWatcherLoss instead of being
  swallowed.

Strict click timing:
- When no pre-click frame qualifies, strict mode previously fell through to a
  fresh (post-click) shot and stored it — contradicting the strict promise.
  It now skips with a capture:diagnostic instead. Non-strict (balanced) mode
  keeps the fresh-shot fallback. Existing tests that exercised the fallback
  now run in balanced mode; the strict test asserts the skip.

Other:
- pathToFileURL replaces file://${p} concatenation for step image URLs and
  export previews (correct for spaces, #, %, drive letters).
- Best-effort click-queue drain on app shutdown (before-quit) so a burst just
  before quit is not lost; bounded so quit never hangs.

Tests: region rect clamping/normalization, power-held-only-while-recording
(incl. finish releases), click-source reporting incl. evdev, drain deadline.
230 unit tests pass. Click self-test: markers 3/3 (strict) and burst 8/8
deterministic across runs; the burst scenario runs in balanced mode because
it tests the drain, not strict timing. (arm/debounce remain the pre-existing
Linux capture failures untouched by this PR.)

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 11:50:44 -07:00

121 lines
4.4 KiB
JavaScript

'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const CaptureService = require('../../app/capture');
function makeService({ settings: settingsOverrides, powerPolicy } = {}) {
const settingsData = {
'capture.mode': 'fullscreen',
'capture.delayMs': 0,
...settingsOverrides,
};
return new CaptureService({
store: {},
settings: { get: (k) => (k in settingsData ? settingsData[k] : null) },
getWindow: () => null,
notify: () => {},
powerPolicy,
screenApi: {
getCursorScreenPoint: () => ({ x: 0, y: 0 }),
getAllDisplays: () => [],
},
});
}
// ---- region rect clamping (bug: out-of-bounds / negative drags) -------------
test('overlayRectToImageRect scales, clamps, and normalizes selections', () => {
const svc = makeService();
const display = { bounds: { x: 0, y: 0, width: 100, height: 100 } };
const imgSize = { width: 200, height: 200 }; // 2x DPI
// Simple selection: display px -> image px (2x).
assert.deepEqual(
svc.overlayRectToImageRect({ x: 10, y: 20, w: 30, h: 40 }, display, imgSize),
{ x: 20, y: 40, width: 60, height: 80 }
);
// Negative-size drag (drawn up/left) normalizes to a positive rect.
assert.deepEqual(
svc.overlayRectToImageRect({ x: 40, y: 40, w: -20, h: -20 }, display, imgSize),
{ x: 40, y: 40, width: 40, height: 40 }
);
// Selection larger than the screen is clamped to the image bounds.
const clamped = svc.overlayRectToImageRect({ x: -10, y: -10, w: 200, h: 200 }, display, imgSize);
assert.deepEqual(clamped, { x: 0, y: 0, width: 200, height: 200 });
// Degenerate selections return null instead of an out-of-bounds crop.
assert.equal(svc.overlayRectToImageRect({ x: 0, y: 0, w: 0, h: 0 }, display, imgSize), null);
assert.equal(svc.overlayRectToImageRect({ x: 999, y: 999, w: 10, h: 10 }, display, imgSize), null);
assert.equal(svc.overlayRectToImageRect(null, display, imgSize), null);
});
// ---- power ownership follows recording state --------------------------------
test('the power blocker is held only while actively recording', () => {
const calls = [];
const powerPolicy = { setRecording: (on) => calls.push(on) };
const svc = makeService({ powerPolicy });
// startSession begins PAUSED — must not hold power.
svc.startSession('g1', { intervalSec: 0 });
assert.deepEqual(calls, [], 'a paused new session must not start the power blocker');
// Resume records -> power on. (togglePause(false) arms recording.)
svc.togglePause(false);
assert.deepEqual(calls, [true]);
// Pause -> power off. This is the tray/second-instance path that used to leak.
svc.togglePause(true);
assert.deepEqual(calls, [true, false]);
// Resume again -> on, finish -> off.
svc.togglePause(false);
svc.finishSession();
assert.deepEqual(calls, [true, false, true, false]);
});
test('finishSession releases power even if it was recording', () => {
const calls = [];
const svc = makeService({ powerPolicy: { setRecording: (on) => calls.push(on) } });
svc.startSession('g1', { intervalSec: 0 });
svc.togglePause(false);
calls.length = 0;
svc.finishSession();
assert.deepEqual(calls, [false]);
});
// ---- explicit click-source reporting ----------------------------------------
test('click source is unavailable outside a session and after stop', () => {
const svc = makeService();
assert.equal(svc.state().clickSource, 'unavailable');
assert.equal(svc.state().clickCapture, undefined); // no session -> no field
svc.clickSource = 'evdev-x11';
svc.stopClickWatcher();
assert.equal(svc.clickSource, 'unavailable');
});
test('state reports clickCapture true for a non-process (evdev) source', () => {
const svc = makeService();
svc.session = { guideId: 'g', paused: false, count: 0, intervalSec: 0 };
// evdev has no child process; the old Boolean(clickWatcher) reported false.
svc.clickSource = 'evdev-wayland';
const st = svc.state();
assert.equal(st.clickCapture, true);
assert.equal(st.clickSource, 'evdev-wayland');
});
// ---- drain never hangs quit -------------------------------------------------
test('drainPendingClicks resolves within the deadline even if the queue hangs', async () => {
const svc = makeService();
svc.clickQueue = new Promise(() => {}); // never settles
const start = Date.now();
await svc.drainPendingClicks(60);
assert.ok(Date.now() - start < 1000, 'drain must not block on a hung queue');
});