Rearchitect click capture: strict click-time frames, off-main-process recorder, exact marker coordinates
Template tests / tests (push) Successful in 1m50s
Template tests / tests (push) Successful in 1m50s
Implements the architecture change from ai_prompts/prompt3.md: - New app/click-frames.js: shared timestamped frame ring + strict click-to-frame pairing (never a frame whose grab started after the click); legacy slack behavior kept behind capture.strictClickFrames=false. - New stream capture backend (app/stream-backend.js + hidden worker window): per-display desktop media streams sampled into ring buffers and PNG-encoded entirely off the main process, so click delivery is never starved by capture work. Auto-degrades to the legacy in-process frame loop when streams cannot start or the worker stops answering. - Clicks are paired with their frame at event time (eager pairing in enqueueClickCapture); only the storing is serialized, so slow encodes cannot skew later clicks in a fast burst. - Linux watcher: restored event-time root coordinates from xinput test-xi2 and merge raw/regular twin events structurally. - Replaced the 40ms time debounce with source-aware duplicate suppression: fast legitimate clicks are never dropped. - New app/coords.js: physical-to-DIP conversion with multi-monitor and scale-factor handling; Windows keeps screenToDipPoint. - STEPFORGE_CLICK_SELFTEST end-to-end hook: 3/3 clicks become steps via the stream backend with 0.00% marker offset on this host. - Tests rewritten/added: strict selection, coords, stream backend, Linux coordinate parsing, twin merge, burst clicking (126 passing). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+408
-51
@@ -5,17 +5,20 @@ const assert = require('node:assert/strict');
|
||||
|
||||
const CaptureService = require('../../app/capture');
|
||||
|
||||
function makeService() {
|
||||
function makeService({ settings: settingsOverrides, screenApi } = {}) {
|
||||
const store = {
|
||||
addStep() {
|
||||
throw new Error('not used in this test');
|
||||
},
|
||||
};
|
||||
const settingsData = {
|
||||
'capture.mode': 'fullscreen',
|
||||
'capture.delayMs': 0,
|
||||
...settingsOverrides,
|
||||
};
|
||||
const settings = {
|
||||
get(key) {
|
||||
if (key === 'capture.mode') return 'fullscreen';
|
||||
if (key === 'capture.delayMs') return 0;
|
||||
return null;
|
||||
return key in settingsData ? settingsData[key] : null;
|
||||
},
|
||||
};
|
||||
return new CaptureService({
|
||||
@@ -23,9 +26,31 @@ function makeService() {
|
||||
settings,
|
||||
getWindow: () => null,
|
||||
notify: () => {},
|
||||
screenApi: screenApi || {
|
||||
getCursorScreenPoint: () => ({ x: 0, y: 0 }),
|
||||
getAllDisplays: () => [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// The raw/regular twin window plus margin: how long a test must wait for a
|
||||
// held Linux raw press to fire when no coordinate twin arrives.
|
||||
const TWIN_FLUSH_MS = 60;
|
||||
const settle = (ms = TWIN_FLUSH_MS) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function makeFrame(name, ageMs = 0, overrides = {}) {
|
||||
return {
|
||||
mode: overrides.mode || 'fullscreen',
|
||||
png: Buffer.from(name),
|
||||
size: overrides.size || { width: 100, height: 100 },
|
||||
display: overrides.display || { bounds: { x: 0, y: 0, width: 100, height: 100 } },
|
||||
cursor: overrides.cursor || { x: 50, y: 50 },
|
||||
capturedAt: Date.now() - ageMs,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- fresh-shot fallback path ----------------------------------------------
|
||||
|
||||
test('click-triggered session capture uses the low-latency hide pause', async () => {
|
||||
const service = makeService();
|
||||
service.session = { guideId: 'guide-1', paused: false, count: 0, intervalSec: 0 };
|
||||
@@ -50,16 +75,7 @@ test('click-triggered session capture uses the low-latency hide pause', async ()
|
||||
});
|
||||
});
|
||||
|
||||
function makeFrame(name, ageMs = 0, overrides = {}) {
|
||||
return {
|
||||
mode: overrides.mode || 'fullscreen',
|
||||
png: Buffer.from(name),
|
||||
size: overrides.size || { width: 100, height: 100 },
|
||||
display: overrides.display || { bounds: { x: 0, y: 0, width: 100, height: 100 } },
|
||||
cursor: overrides.cursor || { x: 50, y: 50 },
|
||||
capturedAt: Date.now() - ageMs,
|
||||
};
|
||||
}
|
||||
// ---- Linux watcher parsing ---------------------------------------------------
|
||||
|
||||
test('rapid click watcher bursts are parsed one click at a time', () => {
|
||||
const service = makeService();
|
||||
@@ -78,7 +94,7 @@ test('rapid click watcher bursts are parsed one click at a time', () => {
|
||||
assert.equal(clicks, 2);
|
||||
});
|
||||
|
||||
test('button presses fire on the detail line; scroll-wheel ticks are ignored', () => {
|
||||
test('raw button presses fire; scroll-wheel ticks (buttons 4-7) are ignored', async () => {
|
||||
const service = makeService();
|
||||
let clicks = 0;
|
||||
service.onOsClick = () => {
|
||||
@@ -101,9 +117,78 @@ test('button presses fire on the detail line; scroll-wheel ticks are ignored', (
|
||||
' detail: 3',
|
||||
].join('\n'), 'linux');
|
||||
|
||||
await settle(); // raw presses hold briefly for a coordinate twin
|
||||
assert.equal(clicks, 2, 'buttons 4-7 are scroll ticks, not clicks');
|
||||
});
|
||||
|
||||
test('regular ButtonPress blocks carry their root coordinates into onOsClick', () => {
|
||||
// The event-time root position is what keeps the marker on the real click
|
||||
// even when the pointer keeps moving after the press — a live cursor read
|
||||
// at parse time would drift.
|
||||
const service = makeService();
|
||||
const seen = [];
|
||||
service.onOsClick = (at, osPoint, button) => {
|
||||
seen.push({ osPoint, button });
|
||||
};
|
||||
|
||||
service.processClickWatcherData([
|
||||
'EVENT type 4 (ButtonPress)',
|
||||
' device: 11 (10)',
|
||||
' detail: 1',
|
||||
' flags:',
|
||||
' root: 644.52/343.55',
|
||||
' event: 644.52/343.55',
|
||||
].join('\n'), 'linux');
|
||||
|
||||
assert.deepEqual(seen, [{ osPoint: { x: 645, y: 344 }, button: 'button-1' }]);
|
||||
});
|
||||
|
||||
test('a raw press and its regular twin merge into a single click with coordinates', async () => {
|
||||
// One physical press can be delivered as both a RawButtonPress and a
|
||||
// ButtonPress block. That duplication is resolved structurally — never by
|
||||
// a time debounce that could swallow real fast clicks.
|
||||
const service = makeService();
|
||||
const seen = [];
|
||||
service.onOsClick = (at, osPoint, button) => {
|
||||
seen.push({ osPoint, button });
|
||||
};
|
||||
|
||||
service.processClickWatcherData([
|
||||
'EVENT type 15 (RawButtonPress)',
|
||||
' device: 11 (11)',
|
||||
' detail: 1',
|
||||
' valuators:',
|
||||
'EVENT type 4 (ButtonPress)',
|
||||
' device: 11 (10)',
|
||||
' detail: 1',
|
||||
' root: 100.00/200.00',
|
||||
].join('\n'), 'linux');
|
||||
|
||||
await settle();
|
||||
assert.deepEqual(seen, [{ osPoint: { x: 100, y: 200 }, button: 'button-1' }],
|
||||
'exactly one click, carrying the regular twin\'s coordinates');
|
||||
});
|
||||
|
||||
test('two genuine fast presses of the same button both fire', async () => {
|
||||
const service = makeService();
|
||||
const seen = [];
|
||||
service.onOsClick = (at, osPoint, button) => {
|
||||
seen.push({ osPoint, button });
|
||||
};
|
||||
|
||||
service.processClickWatcherData([
|
||||
'EVENT type 4 (ButtonPress)',
|
||||
' detail: 1',
|
||||
' root: 10.00/10.00',
|
||||
'EVENT type 4 (ButtonPress)',
|
||||
' detail: 1',
|
||||
' root: 12.00/11.00',
|
||||
].join('\n'), 'linux');
|
||||
|
||||
await settle();
|
||||
assert.equal(seen.length, 2, 'fast clicking must never be dropped by the parser');
|
||||
});
|
||||
|
||||
test('motion events with detail lines do not fire clicks', () => {
|
||||
const service = makeService();
|
||||
let clicks = 0;
|
||||
@@ -121,7 +206,7 @@ test('motion events with detail lines do not fire clicks', () => {
|
||||
assert.equal(clicks, 0);
|
||||
});
|
||||
|
||||
test('event lines split across stdout chunks are reassembled before parsing', () => {
|
||||
test('event lines split across stdout chunks are reassembled before parsing', async () => {
|
||||
const service = makeService();
|
||||
let clicks = 0;
|
||||
service.onOsClick = () => {
|
||||
@@ -132,9 +217,12 @@ test('event lines split across stdout chunks are reassembled before parsing', ()
|
||||
assert.equal(clicks, 0, 'a partial line must not be parsed yet');
|
||||
service.ingestClickWatcherChunk('onPress)\n detail: 1\n', 'linux');
|
||||
|
||||
await settle();
|
||||
assert.equal(clicks, 1);
|
||||
});
|
||||
|
||||
// ---- click queue --------------------------------------------------------------
|
||||
|
||||
test('clicks queue behind an in-progress capture instead of being dropped', async () => {
|
||||
const service = makeService();
|
||||
const order = [];
|
||||
@@ -156,6 +244,57 @@ test('clicks queue behind an in-progress capture instead of being dropped', asyn
|
||||
'the second click must run after the first, not be dropped');
|
||||
});
|
||||
|
||||
test('fast clicks are paired with their frames at event time, not behind the store queue', async () => {
|
||||
// With a slow PNG encode or store, the click queue can run seconds late.
|
||||
// The frame request must go out at click time anyway, or the second
|
||||
// click's frame would be selected (and possibly evicted) far too late.
|
||||
const service = makeService();
|
||||
service.session = { guideId: 'guide-eager', paused: false, count: 0, intervalSec: 0 };
|
||||
service.userIsInApp = () => false;
|
||||
const requested = [];
|
||||
let releaseFirst;
|
||||
const firstGate = new Promise((r) => { releaseFirst = r; });
|
||||
service.frameForClick = (clickPos, clickAt) => {
|
||||
requested.push(clickAt);
|
||||
const frame = makeFrame(`frame-${clickAt}`);
|
||||
return clickAt === 1000 ? firstGate.then(() => frame) : Promise.resolve(frame);
|
||||
};
|
||||
let stored = 0;
|
||||
service.storeFrameAsStep = () => {
|
||||
stored += 1;
|
||||
return { ok: true, step: { stepId: `step-${stored}` } };
|
||||
};
|
||||
|
||||
service.enqueueClickCapture({ x: 1, y: 1 }, 1000, 'left');
|
||||
const queue = service.enqueueClickCapture({ x: 2, y: 2 }, 1040, 'left');
|
||||
|
||||
assert.deepEqual(requested, [1000, 1040],
|
||||
'both frames must be requested immediately, while the first store is still pending');
|
||||
releaseFirst();
|
||||
await queue;
|
||||
assert.equal(stored, 2);
|
||||
assert.equal(service.session.count, 2);
|
||||
});
|
||||
|
||||
test('queued click captures preserve the original event time and button', async () => {
|
||||
const service = makeService();
|
||||
const seen = [];
|
||||
service.sessionCapture = async (trigger, clickPos, clickMeta) => {
|
||||
seen.push({ trigger, clickPos, clickMeta });
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
await service.enqueueClickCapture({ x: 7, y: 8 }, 1770000000456, 'left');
|
||||
|
||||
assert.deepEqual(seen, [{
|
||||
trigger: 'click',
|
||||
clickPos: { x: 7, y: 8 },
|
||||
clickMeta: { at: 1770000000456, button: 'left' },
|
||||
}]);
|
||||
});
|
||||
|
||||
// ---- Windows watcher parsing ---------------------------------------------------
|
||||
|
||||
test('windows click watcher output is counted line by line', () => {
|
||||
const service = makeService();
|
||||
let clicks = 0;
|
||||
@@ -197,6 +336,105 @@ test('windows hook click lines carry button and event timestamp', () => {
|
||||
}]);
|
||||
});
|
||||
|
||||
// ---- click dedupe (source-aware, not a debounce) -------------------------------
|
||||
|
||||
test('fast same-button hook clicks are all captured — there is no time debounce', () => {
|
||||
const service = makeService();
|
||||
service.session = { guideId: 'guide-burst', paused: false, count: 0, intervalSec: 0 };
|
||||
const seen = [];
|
||||
service.enqueueClickCapture = (clickPos, at) => {
|
||||
seen.push(at);
|
||||
};
|
||||
|
||||
// A 5-click burst 15ms apart — faster than the old 40ms debounce allowed.
|
||||
const base = 1770000000000;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
service.onOsClick(base + i * 15, { x: 100 + i, y: 200 }, 'left');
|
||||
}
|
||||
|
||||
assert.equal(seen.length, 5, 'every distinct hook event is one click');
|
||||
});
|
||||
|
||||
test('duplicate delivery of one physical press is suppressed', () => {
|
||||
const service = makeService();
|
||||
service.session = { guideId: 'guide-dupe', paused: false, count: 0, intervalSec: 0 };
|
||||
const seen = [];
|
||||
service.enqueueClickCapture = (clickPos, at) => {
|
||||
seen.push(at);
|
||||
};
|
||||
|
||||
// Same button, same coordinates, 3ms apart: the same event delivered twice.
|
||||
service.onOsClick(1770000000000, { x: 50, y: 60 }, 'left');
|
||||
service.onOsClick(1770000000003, { x: 50, y: 60 }, 'left');
|
||||
// Different coordinates inside the same window: a real second click.
|
||||
service.onOsClick(1770000000006, { x: 80, y: 60 }, 'left');
|
||||
// Different button inside the same window: also real.
|
||||
service.onOsClick(1770000000007, { x: 80, y: 60 }, 'right');
|
||||
|
||||
assert.deepEqual(seen, [1770000000000, 1770000000006, 1770000000007]);
|
||||
});
|
||||
|
||||
// ---- coordinate conversion ------------------------------------------------------
|
||||
|
||||
test('hook coordinates are converted physical → DIP via screenToDipPoint when available', () => {
|
||||
const service = makeService({
|
||||
screenApi: {
|
||||
screenToDipPoint: (p) => ({ x: p.x / 2, y: p.y / 2 }),
|
||||
getCursorScreenPoint: () => { throw new Error('must not fall back to a cursor read'); },
|
||||
},
|
||||
});
|
||||
service.session = { guideId: 'guide-dip', paused: false, count: 0, intervalSec: 0 };
|
||||
const seen = [];
|
||||
service.enqueueClickCapture = (clickPos) => {
|
||||
seen.push(clickPos);
|
||||
};
|
||||
|
||||
service.onOsClick(1770000000000, { x: 1280, y: 640 }, 'left');
|
||||
|
||||
assert.deepEqual(seen, [{ x: 640, y: 320 }]);
|
||||
});
|
||||
|
||||
test('without screenToDipPoint, coordinates convert via display geometry (Linux/X11)', () => {
|
||||
const service = makeService({
|
||||
screenApi: {
|
||||
getAllDisplays: () => [
|
||||
{ id: 1, scaleFactor: 2, bounds: { x: 0, y: 0, width: 1440, height: 900 } },
|
||||
],
|
||||
getCursorScreenPoint: () => { throw new Error('must not fall back to a cursor read'); },
|
||||
},
|
||||
});
|
||||
service.session = { guideId: 'guide-x11', paused: false, count: 0, intervalSec: 0 };
|
||||
const seen = [];
|
||||
service.enqueueClickCapture = (clickPos) => {
|
||||
seen.push(clickPos);
|
||||
};
|
||||
|
||||
service.onOsClick(1770000000000, { x: 1500, y: 900 }, 'button-1');
|
||||
|
||||
assert.deepEqual(seen, [{ x: 750, y: 450 }],
|
||||
'a physical click on a 2x display must land at the halved DIP point');
|
||||
});
|
||||
|
||||
test('clicks without event coordinates fall back to a live cursor read', () => {
|
||||
const service = makeService({
|
||||
screenApi: {
|
||||
getCursorScreenPoint: () => ({ x: 11, y: 22 }),
|
||||
getAllDisplays: () => [],
|
||||
},
|
||||
});
|
||||
service.session = { guideId: 'guide-cursor', paused: false, count: 0, intervalSec: 0 };
|
||||
const seen = [];
|
||||
service.enqueueClickCapture = (clickPos) => {
|
||||
seen.push(clickPos);
|
||||
};
|
||||
|
||||
service.onOsClick(1770000000000, null, 'mouse');
|
||||
|
||||
assert.deepEqual(seen, [{ x: 11, y: 22 }]);
|
||||
});
|
||||
|
||||
// ---- watcher loss -----------------------------------------------------------------
|
||||
|
||||
test('losing the click watcher mid-session falls back to interval capture', () => {
|
||||
const service = makeService();
|
||||
service.settings.get = (key) => (key === 'capture.autoIntervalSec' ? 3 : null);
|
||||
@@ -217,6 +455,8 @@ test('losing the click watcher mid-session falls back to interval capture', () =
|
||||
}
|
||||
});
|
||||
|
||||
// ---- strict frame selection -----------------------------------------------------
|
||||
|
||||
test('a click is served instantly from the freshly buffered frame', async () => {
|
||||
const service = makeService();
|
||||
service.session = { guideId: 'guide-2', paused: false, count: 0, intervalSec: 0 };
|
||||
@@ -264,36 +504,25 @@ test('click capture uses the newest frame completed before the click time', asyn
|
||||
assert.deepEqual(added, ['before-click']);
|
||||
});
|
||||
|
||||
test('queued click captures preserve the original event time and button', async () => {
|
||||
const service = makeService();
|
||||
const seen = [];
|
||||
service.sessionCapture = async (trigger, clickPos, clickMeta) => {
|
||||
seen.push({ trigger, clickPos, clickMeta });
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
await service.enqueueClickCapture({ x: 7, y: 8 }, 1770000000456, 'left');
|
||||
|
||||
assert.deepEqual(seen, [{
|
||||
trigger: 'click',
|
||||
clickPos: { x: 7, y: 8 },
|
||||
clickMeta: { at: 1770000000456, button: 'left' },
|
||||
}]);
|
||||
});
|
||||
|
||||
test('a buffered frame from a different display is ignored for click capture', async () => {
|
||||
const service = makeService();
|
||||
const clickAt = Date.now();
|
||||
service.session = { guideId: 'guide-display', paused: false, count: 0, intervalSec: 0 };
|
||||
service.frameLoopRunning = true;
|
||||
service.frameLoopInFlight = true;
|
||||
service.frameLoopGrabStartedAt = clickAt - 10; // the in-flight grab predates the click
|
||||
service.latestFrame = makeFrame('wrong-display', 0, {
|
||||
display: { bounds: { x: 0, y: 0, width: 100, height: 100 } },
|
||||
});
|
||||
|
||||
service.nextFrame = async () => makeFrame('right-display', 0, {
|
||||
display: { bounds: { x: 100, y: 0, width: 100, height: 100 } },
|
||||
cursor: { x: 150, y: 10 },
|
||||
});
|
||||
service.nextFrame = async () => {
|
||||
const f = makeFrame('right-display', 0, {
|
||||
display: { bounds: { x: 100, y: 0, width: 100, height: 100 } },
|
||||
cursor: { x: 150, y: 10 },
|
||||
});
|
||||
f.startedAt = clickAt - 10;
|
||||
return f;
|
||||
};
|
||||
service.shoot = async () => {
|
||||
throw new Error('click capture should not fall back when a matching frame arrives');
|
||||
};
|
||||
@@ -304,7 +533,7 @@ test('a buffered frame from a different display is ignored for click capture', a
|
||||
return { stepId: 'step-display' };
|
||||
};
|
||||
|
||||
const result = await service.sessionCapture('click', { x: 150, y: 10 });
|
||||
const result = await service.sessionCapture('click', { x: 150, y: 10 }, { at: clickAt });
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(added, ['right-display']);
|
||||
@@ -328,12 +557,35 @@ test('a stale buffered frame is not reused — the click falls back to a fresh s
|
||||
assert.equal(shootCalled, true, 'a stale buffered frame must not be reused');
|
||||
});
|
||||
|
||||
test('an idle click capture waits for the imminent loop frame instead of racing it', async () => {
|
||||
// Grabs take seconds while the idle gap is ~200ms, so the loop's next
|
||||
// frame both starts sooner and avoids stalling the loop the way a
|
||||
// competing one-off shot would.
|
||||
test('strict mode: a frame whose grab started after the click is rejected', async () => {
|
||||
// This replaces the old "idle click waits for the imminent loop frame"
|
||||
// behavior: a grab that begins after the click can already show the
|
||||
// click's effects, so strict mode takes the explicit fresh-shot fallback
|
||||
// instead of passing it off as the click-time screen.
|
||||
const service = makeService();
|
||||
service.session = { guideId: 'guide-idle', paused: false, count: 0, intervalSec: 0 };
|
||||
service.session = { guideId: 'guide-strict', paused: false, count: 0, intervalSec: 0 };
|
||||
service.frameLoopRunning = true;
|
||||
service.frameLoopInFlight = false; // nothing in flight at click time
|
||||
|
||||
const clickAt = Date.now();
|
||||
service.nextFrame = async () => {
|
||||
throw new Error('strict idle clicks must not wait for a post-click frame');
|
||||
};
|
||||
let shootCalled = false;
|
||||
service.shoot = async () => {
|
||||
shootCalled = true;
|
||||
return { ok: true, step: { stepId: 'fresh-step' } };
|
||||
};
|
||||
|
||||
const result = await service.sessionCapture('click', { x: 1, y: 1 }, { at: clickAt });
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(shootCalled, true);
|
||||
});
|
||||
|
||||
test('balanced mode keeps the legacy slack: an imminent post-click frame is accepted', async () => {
|
||||
const service = makeService({ settings: { 'capture.strictClickFrames': false } });
|
||||
service.session = { guideId: 'guide-balanced', paused: false, count: 0, intervalSec: 0 };
|
||||
service.frameLoopRunning = true;
|
||||
service.frameLoopInFlight = false;
|
||||
|
||||
@@ -345,12 +597,12 @@ test('an idle click capture waits for the imminent loop frame instead of racing
|
||||
return f;
|
||||
};
|
||||
service.shoot = async () => {
|
||||
throw new Error('idle clicks must wait for the loop frame, not take a fresh shot');
|
||||
throw new Error('balanced idle clicks wait for the loop frame');
|
||||
};
|
||||
const added = [];
|
||||
service.store.addStep = (guideId, fields, png) => {
|
||||
added.push(png.toString());
|
||||
return { stepId: 'idle-step' };
|
||||
return { stepId: 'balanced-step' };
|
||||
};
|
||||
|
||||
const result = await service.sessionCapture('click', { x: 1, y: 1 }, { at: clickAt });
|
||||
@@ -359,8 +611,8 @@ test('an idle click capture waits for the imminent loop frame instead of racing
|
||||
assert.deepEqual(added, ['next-loop-frame']);
|
||||
});
|
||||
|
||||
test('a loop frame started too long after the click falls back to a fresh shot', async () => {
|
||||
const service = makeService();
|
||||
test('balanced mode: a loop frame started too long after the click still falls back', async () => {
|
||||
const service = makeService({ settings: { 'capture.strictClickFrames': false } });
|
||||
service.session = { guideId: 'guide-late', paused: false, count: 0, intervalSec: 0 };
|
||||
service.frameLoopRunning = true;
|
||||
service.frameLoopInFlight = false;
|
||||
@@ -384,7 +636,7 @@ test('a loop frame started too long after the click falls back to a fresh shot',
|
||||
assert.equal(shootCalled, true, 'late frames must not be passed off as the click-time screen');
|
||||
});
|
||||
|
||||
test('clicks during an in-flight grab wait for the frame instead of being dropped', async () => {
|
||||
test('clicks during an in-flight pre-click grab wait for the frame instead of being dropped', async () => {
|
||||
const service = makeService();
|
||||
service.session = { guideId: 'guide-fast', paused: false, count: 0, intervalSec: 0 };
|
||||
service.frameLoopRunning = true; // a grab is in flight, no frame buffered yet
|
||||
@@ -395,10 +647,16 @@ test('clicks during an in-flight grab wait for the frame instead of being droppe
|
||||
throw new Error('waiting clicks must use the loop frame, not a competing shot');
|
||||
};
|
||||
const added = [];
|
||||
const frames = [];
|
||||
service.store.addStep = (guideId, fields, png) => {
|
||||
added.push(png.toString());
|
||||
return { stepId: `step-${added.length}` };
|
||||
};
|
||||
const origStore = service.storeFrameAsStep.bind(service);
|
||||
service.storeFrameAsStep = (guideId, mode, frame, clickPos) => {
|
||||
frames.push(frame);
|
||||
return origStore(guideId, mode, frame, clickPos);
|
||||
};
|
||||
|
||||
// Two rapid clicks land before the grab completes.
|
||||
const first = service.sessionCapture('click', { x: 1, y: 1 }, { at: clickAt });
|
||||
@@ -410,23 +668,122 @@ test('clicks during an in-flight grab wait for the frame instead of being droppe
|
||||
|
||||
assert.equal(r1.ok, true);
|
||||
assert.equal(r2.ok, true);
|
||||
assert.deepEqual(added, ['loop-frame', 'loop-frame'],
|
||||
'both clicks must become steps from the frame that was in flight');
|
||||
assert.equal(added.length, 2, 'one step per click — fast clicks are never dropped');
|
||||
assert.equal(service.session.count, 2);
|
||||
for (const frame of frames) {
|
||||
assert.ok(frame.startedAt <= clickAt,
|
||||
'strict mode: no step may use a frame whose grab started after its click');
|
||||
}
|
||||
});
|
||||
|
||||
test('pausing stops the frame loop and discards the buffered frame', () => {
|
||||
// ---- stream backend integration ---------------------------------------------------
|
||||
|
||||
test('click frames come from the stream backend when it is active', async () => {
|
||||
const service = makeService();
|
||||
const clickAt = Date.now();
|
||||
service.session = { guideId: 'guide-stream', paused: false, count: 0, intervalSec: 0 };
|
||||
const requests = [];
|
||||
service.streamBackend = {
|
||||
isActive: () => true,
|
||||
frameForClick: async (req) => {
|
||||
requests.push(req);
|
||||
return {
|
||||
mode: 'fullscreen',
|
||||
png: Buffer.from('stream-frame'),
|
||||
size: { width: 200, height: 100 },
|
||||
display: { bounds: { x: 0, y: 0, width: 100, height: 100 } },
|
||||
startedAt: clickAt - 50,
|
||||
capturedAt: clickAt - 40,
|
||||
source: 'stream',
|
||||
};
|
||||
},
|
||||
stop: () => {},
|
||||
};
|
||||
service.shoot = async () => {
|
||||
throw new Error('the stream frame must be used, not a fresh shot');
|
||||
};
|
||||
const added = [];
|
||||
service.store.addStep = (guideId, fields, png) => {
|
||||
added.push(png.toString());
|
||||
return { stepId: 'stream-step' };
|
||||
};
|
||||
|
||||
const result = await service.sessionCapture('click', { x: 10, y: 10 }, { at: clickAt });
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(added, ['stream-frame']);
|
||||
assert.deepEqual(requests, [{ clickPos: { x: 10, y: 10 }, clickAt, strict: true }],
|
||||
'the worker receives the hook-time click timestamp and strictness');
|
||||
});
|
||||
|
||||
test('a stream backend with no qualifying frame falls through to the fresh-shot path', async () => {
|
||||
const service = makeService();
|
||||
service.session = { guideId: 'guide-stream-miss', paused: false, count: 0, intervalSec: 0 };
|
||||
service.streamBackend = {
|
||||
isActive: () => true,
|
||||
frameForClick: async () => null,
|
||||
stop: () => {},
|
||||
};
|
||||
let shootCalled = false;
|
||||
service.shoot = async () => {
|
||||
shootCalled = true;
|
||||
return { ok: true, step: { stepId: 'fresh-step' } };
|
||||
};
|
||||
|
||||
const result = await service.sessionCapture('click', { x: 1, y: 1 });
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(shootCalled, true);
|
||||
});
|
||||
|
||||
test('pausing stops the frame loop, drops buffered frames, and stops the stream backend', () => {
|
||||
const service = makeService();
|
||||
service.session = { guideId: 'guide-pause', paused: false, count: 0, intervalSec: 0 };
|
||||
service.frameLoopRunning = true;
|
||||
service.latestFrame = makeFrame('pre-pause');
|
||||
let backendStopped = false;
|
||||
service.streamBackend = { isActive: () => true, stop: () => { backendStopped = true; } };
|
||||
|
||||
service.togglePause(true);
|
||||
|
||||
assert.equal(service.frameLoopRunning, false);
|
||||
assert.equal(service.latestFrame, null, 'a resume must never serve a pre-pause frame');
|
||||
assert.equal(backendStopped, true);
|
||||
assert.equal(service.streamBackend, null);
|
||||
});
|
||||
|
||||
test('an unhealthy stream backend degrades to the in-process frame loop', () => {
|
||||
const service = makeService();
|
||||
service.session = { guideId: 'guide-degrade', paused: false, count: 0, intervalSec: 0 };
|
||||
service.streamBackend = { isActive: () => true, stop: () => {} };
|
||||
let loopStarted = false;
|
||||
service.startFrameLoop = () => { loopStarted = true; };
|
||||
const states = [];
|
||||
service.notify = (channel) => states.push(channel);
|
||||
|
||||
service.degradeToFrameLoop();
|
||||
|
||||
assert.equal(service.streamBackend, null);
|
||||
assert.equal(loopStarted, true, 'capture must not silently stop when the worker dies');
|
||||
assert.ok(states.includes('capture:state'));
|
||||
});
|
||||
|
||||
test('session state reports which frame recorder is serving clicks', () => {
|
||||
const service = makeService();
|
||||
service.session = { guideId: 'guide-state', paused: false, count: 0, intervalSec: 0 };
|
||||
|
||||
assert.equal(service.state().clickFrameSource, 'idle');
|
||||
assert.equal(service.state().strictClickFrames, true);
|
||||
service.frameLoopRunning = true;
|
||||
assert.equal(service.state().clickFrameSource, 'loop');
|
||||
service.streamBackend = { isActive: () => true, stop: () => {} };
|
||||
assert.equal(service.state().clickFrameSource, 'stream');
|
||||
service.streamBackend = null;
|
||||
service.frameLoopRunning = false;
|
||||
});
|
||||
|
||||
// ---- marker + session lifecycle ------------------------------------------------
|
||||
|
||||
test('click capture marks the click-time cursor position', async () => {
|
||||
const service = makeService();
|
||||
service.settings.get = (key) => {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
FrameRing,
|
||||
frameUsableForClick,
|
||||
selectFrameForClick,
|
||||
} = require('../../app/click-frames');
|
||||
|
||||
function frame(name, { startedAt, capturedAt, mode = 'fullscreen', display = null } = {}) {
|
||||
return { name, mode, startedAt, capturedAt, display };
|
||||
}
|
||||
|
||||
// ---- FrameRing --------------------------------------------------------------
|
||||
|
||||
test('the ring keeps at most `limit` frames and drops the oldest first', () => {
|
||||
let now = 1000;
|
||||
const evicted = [];
|
||||
const ring = new FrameRing({ limit: 2, retentionMs: 60_000, now: () => now, onEvict: (f) => evicted.push(f.name) });
|
||||
ring.push(frame('a', { capturedAt: 1000 }));
|
||||
ring.push(frame('b', { capturedAt: 1100 }));
|
||||
now = 1200;
|
||||
ring.push(frame('c', { capturedAt: 1200 }));
|
||||
|
||||
assert.deepEqual(ring.frames().map((f) => f.name), ['b', 'c']);
|
||||
assert.deepEqual(evicted, ['a'], 'eviction must release the dropped frame');
|
||||
assert.equal(ring.latest().name, 'c');
|
||||
});
|
||||
|
||||
test('the ring evicts frames older than the retention window', () => {
|
||||
let now = 1000;
|
||||
const ring = new FrameRing({ limit: 10, retentionMs: 500, now: () => now });
|
||||
ring.push(frame('old', { capturedAt: 1000 }));
|
||||
now = 2000;
|
||||
ring.push(frame('new', { capturedAt: 2000 }));
|
||||
|
||||
assert.deepEqual(ring.frames().map((f) => f.name), ['new']);
|
||||
});
|
||||
|
||||
test('clear() releases every frame through onEvict', () => {
|
||||
const evicted = [];
|
||||
const ring = new FrameRing({ onEvict: (f) => evicted.push(f.name) });
|
||||
ring.push(frame('a', { capturedAt: Date.now() }));
|
||||
ring.push(frame('b', { capturedAt: Date.now() }));
|
||||
ring.clear();
|
||||
|
||||
assert.deepEqual(ring.frames(), []);
|
||||
assert.deepEqual(evicted.sort(), ['a', 'b']);
|
||||
});
|
||||
|
||||
// ---- strict selection -------------------------------------------------------
|
||||
|
||||
test('strict mode picks the newest frame completed at or before the click', () => {
|
||||
const clickAt = 10_000;
|
||||
const frames = [
|
||||
frame('older', { startedAt: 9300, capturedAt: 9400 }),
|
||||
frame('best', { startedAt: 9800, capturedAt: 9900 }),
|
||||
frame('post-click', { startedAt: 10_050, capturedAt: 10_150 }),
|
||||
];
|
||||
|
||||
const chosen = selectFrameForClick(frames, { clickAt, mode: 'fullscreen', strict: true });
|
||||
|
||||
assert.equal(chosen.name, 'best');
|
||||
});
|
||||
|
||||
test('strict mode never accepts a frame whose grab started after the click', () => {
|
||||
const clickAt = 10_000;
|
||||
// Even one millisecond after the click, and even via the in-flight path:
|
||||
// a post-click grab can already show the click's effects.
|
||||
const f = frame('late', { startedAt: 10_001, capturedAt: 10_200 });
|
||||
|
||||
assert.equal(frameUsableForClick(f, { clickAt, strict: true, allowInFlight: true }), false);
|
||||
assert.equal(selectFrameForClick([f], { clickAt, strict: true }), null);
|
||||
});
|
||||
|
||||
test('strict mode accepts an in-flight frame whose grab started before the click', () => {
|
||||
const clickAt = 10_000;
|
||||
const f = frame('in-flight', { startedAt: 9950, capturedAt: 10_300 });
|
||||
|
||||
assert.equal(frameUsableForClick(f, { clickAt, strict: true, allowInFlight: true }), true);
|
||||
assert.equal(frameUsableForClick(f, { clickAt, strict: true, allowInFlight: false }), false,
|
||||
'a not-yet-needed in-flight frame must not be selected from the buffer path');
|
||||
});
|
||||
|
||||
test('a frame older than maxAgeMs is too stale for the click', () => {
|
||||
const clickAt = 10_000;
|
||||
const f = frame('stale', { startedAt: 9000, capturedAt: 9100 });
|
||||
|
||||
assert.equal(frameUsableForClick(f, { clickAt, strict: true, maxAgeMs: 600 }), false);
|
||||
assert.equal(frameUsableForClick(f, { clickAt, strict: true, maxAgeMs: 2000 }), true);
|
||||
});
|
||||
|
||||
test('balanced mode accepts a grab started within the slack window after the click', () => {
|
||||
const clickAt = 10_000;
|
||||
const f = frame('slack', { startedAt: 10_100, capturedAt: 10_350 });
|
||||
|
||||
assert.equal(frameUsableForClick(f, {
|
||||
clickAt, strict: false, allowInFlight: true, startSlackMs: 300,
|
||||
}), true);
|
||||
assert.equal(frameUsableForClick(f, {
|
||||
clickAt, strict: true, allowInFlight: true, startSlackMs: 300,
|
||||
}), false, 'slack acceptance is balanced-mode only');
|
||||
});
|
||||
|
||||
test('frames from another display are rejected when the click position is known', () => {
|
||||
const clickAt = 10_000;
|
||||
const left = { bounds: { x: 0, y: 0, width: 1920, height: 1080 } };
|
||||
const right = { bounds: { x: 1920, y: 0, width: 1920, height: 1080 } };
|
||||
const f = frame('left-screen', { startedAt: 9900, capturedAt: 9950, display: left });
|
||||
|
||||
assert.equal(frameUsableForClick(f, { clickAt, clickPos: { x: 2500, y: 500 } }), false);
|
||||
assert.equal(frameUsableForClick(f, { clickAt, clickPos: { x: 500, y: 500 } }), true);
|
||||
const g = frame('right-screen', { startedAt: 9960, capturedAt: 9980, display: right });
|
||||
assert.equal(selectFrameForClick([f, g], { clickAt, clickPos: { x: 2500, y: 500 } }).name, 'right-screen');
|
||||
});
|
||||
|
||||
test('frames of the wrong capture mode are rejected', () => {
|
||||
const clickAt = 10_000;
|
||||
const f = frame('window-grab', { startedAt: 9900, capturedAt: 9950, mode: 'window' });
|
||||
|
||||
assert.equal(frameUsableForClick(f, { clickAt, mode: 'fullscreen' }), false);
|
||||
assert.equal(frameUsableForClick(f, { clickAt, mode: 'window' }), true);
|
||||
});
|
||||
|
||||
test('a frame without startedAt falls back to capturedAt for the strict check', () => {
|
||||
const clickAt = 10_000;
|
||||
const before = frame('legacy-before', { capturedAt: 9950 });
|
||||
const after = frame('legacy-after', { capturedAt: 10_050 });
|
||||
|
||||
assert.equal(frameUsableForClick(before, { clickAt, strict: true }), true);
|
||||
assert.equal(frameUsableForClick(after, { clickAt, strict: true, allowInFlight: true }), false);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
physicalBoundsOf,
|
||||
physicalToDip,
|
||||
displayForPhysicalPoint,
|
||||
displayForDipPoint,
|
||||
} = require('../../app/coords');
|
||||
|
||||
const display = (id, x, y, width, height, scaleFactor = 1) => ({
|
||||
id, scaleFactor, bounds: { x, y, width, height },
|
||||
});
|
||||
|
||||
test('at 100% scale, physical and DIP coordinates are identical', () => {
|
||||
const displays = [display(1, 0, 0, 1920, 1080, 1)];
|
||||
|
||||
assert.deepEqual(physicalToDip({ x: 640, y: 360 }, displays), { x: 640, y: 360 });
|
||||
});
|
||||
|
||||
test('at 200% scale, physical pixels halve into DIP', () => {
|
||||
// This is the classic marker-offset bug: a click at physical (1500, 900)
|
||||
// on a 2x display is the DIP point (750, 450); drawing the marker at the
|
||||
// raw values lands it far below-right of the real click.
|
||||
const displays = [display(1, 0, 0, 1440, 900, 2)];
|
||||
|
||||
assert.deepEqual(physicalToDip({ x: 1500, y: 900 }, displays), { x: 750, y: 450 });
|
||||
});
|
||||
|
||||
test('fractional scale factors convert exactly', () => {
|
||||
const displays = [display(1, 0, 0, 1280, 800, 1.5)];
|
||||
|
||||
assert.deepEqual(physicalToDip({ x: 960, y: 600 }, displays), { x: 640, y: 400 });
|
||||
});
|
||||
|
||||
test('physical bounds are DIP bounds times the scale factor', () => {
|
||||
assert.deepEqual(physicalBoundsOf(display(1, 100, 50, 1280, 800, 2)),
|
||||
{ x: 200, y: 100, width: 2560, height: 1600 });
|
||||
});
|
||||
|
||||
test('multi-monitor: a click on the secondary display converts in that display space', () => {
|
||||
// Two 1920x1080 displays side by side, uniform 2x scale. Physical x=4800
|
||||
// is the middle of the second display; its DIP x must be 1920 + 480.
|
||||
const displays = [
|
||||
display(1, 0, 0, 1920, 1080, 2),
|
||||
display(2, 1920, 0, 1920, 1080, 2),
|
||||
];
|
||||
|
||||
assert.equal(displayForPhysicalPoint({ x: 4800, y: 500 }, displays).id, 2);
|
||||
assert.deepEqual(physicalToDip({ x: 4800, y: 540 }, displays), { x: 2400, y: 270 });
|
||||
});
|
||||
|
||||
test('multi-monitor with negative origin (display left of primary)', () => {
|
||||
const displays = [
|
||||
display(1, 0, 0, 1920, 1080, 1),
|
||||
display(2, -1920, 0, 1920, 1080, 1),
|
||||
];
|
||||
|
||||
assert.deepEqual(physicalToDip({ x: -960, y: 540 }, displays), { x: -960, y: 540 });
|
||||
assert.equal(displayForPhysicalPoint({ x: -960, y: 540 }, displays).id, 2);
|
||||
});
|
||||
|
||||
test('a point just outside every display maps via the nearest one', () => {
|
||||
// Clicks on the outermost pixel row can round to one pixel outside the
|
||||
// display bounds; they must not be dropped or mapped to the wrong screen.
|
||||
const displays = [display(1, 0, 0, 1920, 1080, 1)];
|
||||
|
||||
assert.deepEqual(physicalToDip({ x: 1921, y: 540 }, displays), { x: 1921, y: 540 });
|
||||
});
|
||||
|
||||
test('no display geometry means no conversion (caller falls back to a cursor read)', () => {
|
||||
assert.equal(physicalToDip({ x: 10, y: 10 }, []), null);
|
||||
assert.equal(physicalToDip(null, [display(1, 0, 0, 100, 100)]), null);
|
||||
assert.equal(physicalToDip({ x: Number.NaN, y: 10 }, [display(1, 0, 0, 100, 100)]), null);
|
||||
});
|
||||
|
||||
test('displayForDipPoint routes a click to the containing display, else the nearest', () => {
|
||||
const displays = [
|
||||
display(1, 0, 0, 1920, 1080, 1),
|
||||
display(2, 1920, 0, 1920, 1080, 1),
|
||||
];
|
||||
|
||||
assert.equal(displayForDipPoint({ x: 2000, y: 10 }, displays).id, 2);
|
||||
assert.equal(displayForDipPoint({ x: 10, y: 10 }, displays).id, 1);
|
||||
assert.equal(displayForDipPoint({ x: 5000, y: 10 }, displays).id, 2, 'nearest display wins for out-of-bounds points');
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { StreamCaptureBackend, pairDisplaysToSources } = require('../../app/stream-backend');
|
||||
|
||||
const display = (id, x, y, width, height, scaleFactor = 1) => ({
|
||||
id, scaleFactor, bounds: { x, y, width, height },
|
||||
});
|
||||
|
||||
/**
|
||||
* Test host: records commands, exposes the backend's event handler so a test
|
||||
* can play the worker's part, and auto-acks start-stream commands so start()
|
||||
* resolves without a real worker window.
|
||||
*/
|
||||
function makeBackend({ autoReady = true, ...opts } = {}) {
|
||||
const sent = [];
|
||||
let emit = null;
|
||||
let destroyed = false;
|
||||
const backend = new StreamCaptureBackend({
|
||||
createHost: async (onEvent) => {
|
||||
emit = onEvent;
|
||||
return {
|
||||
send(msg) {
|
||||
sent.push(msg);
|
||||
if (autoReady && msg.type === 'start-stream') {
|
||||
queueMicrotask(() => emit({ type: 'stream-ready', displayId: msg.displayId }));
|
||||
}
|
||||
},
|
||||
destroy() { destroyed = true; },
|
||||
};
|
||||
},
|
||||
frameTimeoutMs: 40,
|
||||
startTimeoutMs: 100,
|
||||
...opts,
|
||||
});
|
||||
return { backend, sent, worker: (msg) => emit(msg), isDestroyed: () => destroyed };
|
||||
}
|
||||
|
||||
const oneDisplay = [display(7, 0, 0, 1920, 1080, 1)];
|
||||
const oneSource = [{ id: 'screen:1:0', display_id: '7' }];
|
||||
|
||||
test('start() opens one stream per display and reports active once ready', async () => {
|
||||
const { backend, sent } = makeBackend();
|
||||
|
||||
const ok = await backend.start({ displays: oneDisplay, sources: oneSource, sampleMs: 50 });
|
||||
|
||||
assert.equal(ok, true);
|
||||
assert.equal(backend.isActive(), true);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0].type, 'start-stream');
|
||||
assert.equal(sent[0].sourceId, 'screen:1:0');
|
||||
assert.equal(sent[0].sampleMs, 50);
|
||||
assert.deepEqual(sent[0].display.bounds, { x: 0, y: 0, width: 1920, height: 1080 });
|
||||
backend.stop();
|
||||
});
|
||||
|
||||
test('start() fails cleanly when every stream errors', async () => {
|
||||
const { backend, sent, worker, isDestroyed } = makeBackend({ autoReady: false });
|
||||
const startPromise = backend.start({ displays: oneDisplay, sources: oneSource });
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(sent.length, 1);
|
||||
worker({ type: 'stream-error', displayId: 7, reason: 'no permission' });
|
||||
|
||||
const ok = await startPromise;
|
||||
|
||||
assert.equal(ok, false);
|
||||
assert.equal(backend.isActive(), false);
|
||||
assert.equal(isDestroyed(), true, 'a failed start must tear the worker down');
|
||||
});
|
||||
|
||||
test('a frame request resolves with the worker frame, carrying its timestamps and display', async () => {
|
||||
const { backend, sent, worker } = makeBackend();
|
||||
await backend.start({ displays: oneDisplay, sources: oneSource });
|
||||
|
||||
const promise = backend.frameForClick({ clickPos: { x: 100, y: 100 }, clickAt: 5000, strict: true });
|
||||
const request = sent.find((m) => m.type === 'frame-request');
|
||||
assert.ok(request, 'a frame-request must be sent to the worker');
|
||||
assert.equal(request.clickAt, 5000);
|
||||
assert.equal(request.strict, true);
|
||||
assert.equal(request.displayId, 7);
|
||||
worker({
|
||||
type: 'frame-response',
|
||||
requestId: request.requestId,
|
||||
ok: true,
|
||||
png: Uint8Array.from([1, 2, 3]),
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
startedAt: 4900,
|
||||
capturedAt: 4910,
|
||||
});
|
||||
|
||||
const frame = await promise;
|
||||
|
||||
assert.equal(frame.mode, 'fullscreen');
|
||||
assert.deepEqual([...frame.png], [1, 2, 3]);
|
||||
assert.deepEqual(frame.size, { width: 1920, height: 1080 });
|
||||
assert.equal(frame.startedAt, 4900);
|
||||
assert.equal(frame.capturedAt, 4910);
|
||||
assert.equal(frame.display.id, 7);
|
||||
assert.equal(frame.source, 'stream');
|
||||
backend.stop();
|
||||
});
|
||||
|
||||
test('a "no qualifying frame" reply resolves null without counting as a failure', async () => {
|
||||
const { backend, sent, worker } = makeBackend();
|
||||
await backend.start({ displays: oneDisplay, sources: oneSource });
|
||||
|
||||
const promise = backend.frameForClick({ clickAt: 5000 });
|
||||
const request = sent.find((m) => m.type === 'frame-request');
|
||||
worker({ type: 'frame-response', requestId: request.requestId, ok: false, reason: 'click predates first frame' });
|
||||
|
||||
assert.equal(await promise, null);
|
||||
assert.equal(backend.isActive(), true, 'an honest empty answer is healthy');
|
||||
backend.stop();
|
||||
});
|
||||
|
||||
test('clicks on a multi-monitor setup route to the stream of the clicked display', async () => {
|
||||
const displays = [display(1, 0, 0, 1920, 1080), display(2, 1920, 0, 1920, 1080)];
|
||||
const sources = [
|
||||
{ id: 'screen:1:0', display_id: '1' },
|
||||
{ id: 'screen:2:0', display_id: '2' },
|
||||
];
|
||||
const { backend, sent } = makeBackend();
|
||||
await backend.start({ displays, sources });
|
||||
|
||||
backend.frameForClick({ clickPos: { x: 2500, y: 400 }, clickAt: 1 });
|
||||
backend.frameForClick({ clickPos: { x: 300, y: 400 }, clickAt: 2 });
|
||||
|
||||
const requests = sent.filter((m) => m.type === 'frame-request');
|
||||
assert.deepEqual(requests.map((r) => r.displayId), [2, 1]);
|
||||
backend.stop();
|
||||
});
|
||||
|
||||
test('repeated frame-request timeouts mark the backend unhealthy exactly once', async () => {
|
||||
let unhealthy = 0;
|
||||
const { backend, isDestroyed } = makeBackend({ onUnhealthy: () => { unhealthy += 1; } });
|
||||
await backend.start({ displays: oneDisplay, sources: oneSource });
|
||||
|
||||
// Two consecutive timeouts (the worker never answers).
|
||||
assert.equal(await backend.frameForClick({ clickAt: 1 }), null);
|
||||
assert.equal(await backend.frameForClick({ clickAt: 2 }), null);
|
||||
|
||||
assert.equal(unhealthy, 1, 'degradation must fire once, not per click');
|
||||
assert.equal(backend.isActive(), false);
|
||||
assert.equal(isDestroyed(), true);
|
||||
});
|
||||
|
||||
test('a late worker reply after the timeout is ignored', async () => {
|
||||
const { backend, sent, worker } = makeBackend();
|
||||
await backend.start({ displays: oneDisplay, sources: oneSource });
|
||||
|
||||
const result = await backend.frameForClick({ clickAt: 1 }); // times out at 40ms
|
||||
const request = sent.find((m) => m.type === 'frame-request');
|
||||
worker({ type: 'frame-response', requestId: request.requestId, ok: true, png: Uint8Array.from([9]), width: 1, height: 1 });
|
||||
|
||||
assert.equal(result, null);
|
||||
backend.stop();
|
||||
});
|
||||
|
||||
test('stop() resolves all in-flight requests with null', async () => {
|
||||
const { backend } = makeBackend();
|
||||
await backend.start({ displays: oneDisplay, sources: oneSource });
|
||||
|
||||
const pending = backend.frameForClick({ clickAt: 1 });
|
||||
backend.stop();
|
||||
|
||||
assert.equal(await pending, null);
|
||||
assert.equal(backend.isActive(), false);
|
||||
});
|
||||
|
||||
test('displays pair to screen sources by display_id; single display pairs to a lone source', () => {
|
||||
const displays = [display(1, 0, 0, 100, 100), display(2, 100, 0, 100, 100)];
|
||||
const sources = [
|
||||
{ id: 'screen:b', display_id: '2' },
|
||||
{ id: 'screen:a', display_id: '1' },
|
||||
{ id: 'window:x', display_id: '' },
|
||||
];
|
||||
|
||||
assert.deepEqual(pairDisplaysToSources(displays, sources), [
|
||||
{ display: displays[0], sourceId: 'screen:a' },
|
||||
{ display: displays[1], sourceId: 'screen:b' },
|
||||
]);
|
||||
// WSLg and some portals leave display_id empty — a single display still
|
||||
// pairs with the single screen source.
|
||||
assert.deepEqual(
|
||||
pairDisplaysToSources([displays[0]], [{ id: 'screen:0', display_id: '' }]),
|
||||
[{ display: displays[0], sourceId: 'screen:0' }],
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user