Fix restarted recordings dropping clicks / stopping after one click
Template tests / tests (push) Waiting to run
Template tests / tests (pull_request) Waiting to run

Root cause: warm-before-hide kept the window visible during backend warmup,
and on a restart that warmup could take several seconds (the stream backend
start waits up to 8s). During that visible window, clicks over the app were
skipped by the userIsInApp guard and clicks elsewhere were shot post-click,
so a restarted session looked like it stopped after one click.

- Recording is now 'armed' only after the window is hidden and the buffer is
  primed. A new warmingUp flag makes onOsClick ignore clicks during warmup
  (the window is covering the user's work anyway) instead of mishandling
  them. Cleared on pause/finish.
- armRecording caps the warmup wait (WARMUP_MAX_MS=1500): the window hides
  and the session arms even if the backend start hangs, so it can never sit
  visible for seconds dropping clicks. The backend keeps coming up in the
  background; the first click or two may take the fresh-shot fallback.
- A generation token invalidates an in-flight backend start whose session
  has since finished, so a slow start can't install into a new session or
  leave the starting-guard stuck and block the restart from starting one.

Tests: 4 new behavioral capture tests (warmup ignores clicks; pause/finish
clear it; armRecording warms-then-hides-then-arms; a hung start still arms
within the cap; a stale start is discarded and frees the guard) plus a new
end-to-end self-test scenario (warmup click ignored, first armed click
captured). 152 unit tests + all repo checks pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Iisyourdad
2026-06-12 09:17:59 -05:00
parent 3d0b753205
commit f2c5831315
4 changed files with 238 additions and 30 deletions
+28 -18
View File
@@ -195,11 +195,22 @@ function createWindow() {
console.log('CLICK-SELFTEST burst:', burstSteps, 'of', burstCount,
burstSteps === burstCount ? 'OK — no clicks dropped on finish' : 'FAIL — clicks lost');
// Helper: wait until armRecording has finished warming (window
// hidden, buffer primed) so an injected click counts as a real
// recording click rather than being ignored as a warmup click.
const waitArmed = async () => {
for (let i = 0; i < 80 && capture.warmingUp; i++) {
await new Promise((res) => setTimeout(res, 50));
}
};
// Third scenario: the real "Start recording" path. armRecording
// must warm the recorder *before* hiding, so a click right after
// start still gets a pre-click frame instead of the post-click
// fresh shot that made "the first screenshot late". (This host may
// lack xinput, which gates the recorder, so force availability.)
// warms the recorder while the window is visible and only arms the
// session once it hides; the first click *after* arming must get a
// pre-click frame (not the post-click shot that made "the first
// screenshot late"), and a click *during* warmup must be ignored,
// not mishandled. (This host may lack xinput, which gates the
// recorder, so force availability.)
const armGuide = store.createGuide({ title: 'arm selftest' });
mainWindow.show();
await new Promise((res) => setTimeout(res, 300));
@@ -207,25 +218,23 @@ function createWindow() {
capture.stopClickWatcher();
capture.clickCaptureAvailable = () => true;
capture.hiddenForSession = true; // window was visible at session start
capture.togglePause(false); // armRecording: warm → hide
// Click while the old code would still be warming up (~250ms in).
await new Promise((res) => setTimeout(res, 250));
capture.togglePause(false); // armRecording: warm → hide → arm
// A click during warmup must be ignored (window still visible).
await new Promise((res) => setTimeout(res, 200));
const warmupClicks = store.getGuide(armGuide.guideId).stepsOrder.length;
capture.onOsClick(Date.now(), toPhysical({ x: bounds.x + 100, y: bounds.y + 100 }), 'button-1');
await waitArmed();
const armPoint = {
x: Math.round(bounds.x + bounds.width * 0.4),
y: Math.round(bounds.y + bounds.height * 0.4),
};
const armClickAt = Date.now();
capture.onOsClick(armClickAt, toPhysical(armPoint), 'button-1');
capture.onOsClick(Date.now(), toPhysical(armPoint), 'button-1');
await capture.clickQueue;
await new Promise((res) => setTimeout(res, 800));
const armStepIds = store.getGuide(armGuide.guideId).stepsOrder;
let armPreClick = false;
if (armStepIds.length) {
// A pre-click frame is the win; the log line shows the margin.
armPreClick = true;
}
console.log('CLICK-SELFTEST arm: first click ->', armStepIds.length,
'step(s)', armPreClick ? '(see margin in [capture] log above)' : 'FAIL — first click lost');
const armSteps = store.getGuide(armGuide.guideId).stepsOrder.length;
console.log('CLICK-SELFTEST arm: warmup-click steps', warmupClicks,
'-> after-arm steps', armSteps,
armSteps === 1 ? 'OK — warmup click ignored, first armed click captured' : 'FAIL');
capture.finishSession();
// Fourth scenario: the debounce itself, exercised end to end through
@@ -241,7 +250,8 @@ function createWindow() {
capture.hiddenForSession = true;
capture.togglePause(false);
await capture.startClickFrameBackend();
await new Promise((res) => setTimeout(res, 1500));
await waitArmed();
await new Promise((res) => setTimeout(res, 300));
const dbPoint = {
x: Math.round(bounds.x + bounds.width * 0.55),
y: Math.round(bounds.y + bounds.height * 0.55),