Template tests / tests (pull_request) Failing after 33s
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]>
121 lines
4.4 KiB
JavaScript
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');
|
|
});
|