fix(diagnostics): deep-review round 1 — a send that cannot race, lose or repeat itself

The send, the draft and the screenshots take the session's row lock, so an
autosave or a paste during a send waits for it and then sees it, and two sends
cannot open two issues. A save keeps the delivery's progress; a failed edit
that shows the screenshots is finished by the retry; an answer that is not the
tracker's API (a redirect, a proxy page) is refused, not a crash that loses the
issue's record. Only an attempt that opened an issue counts toward the pace.

Screenshots: deleting one after the send is refused, a user's storage is
capped across sessions, new sessions and zip exports are paced, images are
stored uncompressed in the zip. One liveness rule, one session view, one image
type table.

Logging: a session's elevation is tracked apart from the user's own, so the
browser's own requests (no header) no longer lower the floor a session needs;
Stop ends it at once.

Composer: an image pasted anywhere is taken; what is still unsaved is saved on
close, and "saved" is shown only when it is; the draft is held while it is
sent; a failed screenshot read says so instead of reading as none; focus moves
to what stays; the preview follows the text size. Pill: the step prompt and a
running Stop survive the pill moving between hosts, the prompt keeps the
dialog's Tab trap, the toast region lets clicks through, other tabs hear of a
Start or Stop, the last seconds are sent before the cap, a capture with no
frame is an error.
This commit is contained in:
2026-09-20 00:09:52 +02:00
parent 9cf177b198
commit 134ec9a68e
25 changed files with 1007 additions and 208 deletions
+55
View File
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
//
// E16: one screenshot of this tab. The browser's picker is stubbed (jsdom has no Screen Capture API):
// a cancelled or refused picker is the user saying no (null, no error); anything else is an error;
// a tab that was shared but gave no frame is an error too, not a silent nothing; and the capture is
// stopped whatever happened — nothing keeps sharing the tab after one frame.
import { afterEach, describe, expect, it, vi } from "vitest";
import { captureTab } from "./captureTab";
function stubPicker(result: () => Promise<MediaStream>) {
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: { getDisplayMedia: vi.fn(result) },
});
}
function fakeStream() {
const track = { stop: vi.fn() };
return { stream: { getTracks: () => [track] } as unknown as MediaStream, track };
}
afterEach(() => {
vi.restoreAllMocks();
Reflect.deleteProperty(navigator, "mediaDevices");
});
describe("captureTab", () => {
it.each(["NotAllowedError", "AbortError"])(
"a %s from the picker is the user saying no",
async (name) => {
stubPicker(() => Promise.reject(new DOMException("no", name)));
await expect(captureTab()).resolves.toBeNull();
},
);
it("any other failure of the picker is an error", async () => {
stubPicker(() => Promise.reject(new DOMException("broken", "NotReadableError")));
await expect(captureTab()).rejects.toMatchObject({ name: "NotReadableError" });
});
it("a shared tab that gives no frame is an error, and the capture is stopped", async () => {
vi.useFakeTimers();
const { stream, track } = fakeStream();
stubPicker(() => Promise.resolve(stream));
vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
// jsdom's video never has a size: no frame arrives.
const result = captureTab();
const settled = expect(result).rejects.toMatchObject({ name: "NoFrameError" });
await vi.advanceTimersByTimeAsync(2_000);
await settled;
expect(track.stop).toHaveBeenCalled();
vi.useRealTimers();
});
});
+16 -5
View File
@@ -6,7 +6,8 @@
//
// A cancelled or refused picker is `null`, not an error: it is the user saying no. Measured in the
// desktop app's Chromium (2026-09-19): refused capture rejects with `NotAllowedError` — the same
// name Chrome gives a cancelled picker.
// name Chrome gives a cancelled picker. A tab that WAS shared but gave no frame is an error, though:
// the user said yes, and a silent nothing would read as a screenshot taken.
//
// The frame is read through a <video> and a canvas rather than `ImageCapture.grabFrame`, which
// Firefox and Safari lack.
@@ -16,7 +17,8 @@ export function canCaptureTab(): boolean {
return typeof navigator !== "undefined" && !!navigator.mediaDevices?.getDisplayMedia;
}
/** Ask for this tab and return one PNG frame of it, or null when the user did not share. */
/** Ask for this tab and return one PNG frame of it, or null when the user did not share. Throws
* `NoFrameError` when the shared tab gave no frame. */
export async function captureTab(): Promise<Blob | null> {
let stream: MediaStream;
try {
@@ -44,7 +46,14 @@ export async function captureTab(): Promise<Blob | null> {
}
}
async function firstFrame(stream: MediaStream): Promise<Blob | null> {
class NoFrameError extends Error {
constructor() {
super("the shared tab gave no frame");
this.name = "NoFrameError";
}
}
async function firstFrame(stream: MediaStream): Promise<Blob> {
const video = document.createElement("video");
video.muted = true;
video.playsInline = true;
@@ -52,11 +61,13 @@ async function firstFrame(stream: MediaStream): Promise<Blob | null> {
await video.play();
// The first frame can arrive a beat after `play` resolves; wait until it has a size.
for (let i = 0; i < 20 && !video.videoWidth; i++) await new Promise((r) => setTimeout(r, 50));
if (!video.videoWidth) return null;
if (!video.videoWidth) throw new NoFrameError();
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d")?.drawImage(video, 0, 0);
video.srcObject = null;
return new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
if (!blob) throw new NoFrameError();
return blob;
}
+28
View File
@@ -10,6 +10,34 @@ export const openRecorderStart = (): void => startOpen.set(true);
export const closeRecorderStart = (): void => startOpen.set(false);
export const useRecorderStartOpen = (): boolean => startOpen.use();
// The pill's step prompt — open, and what is typed in it — held here, not in the pill: the pill
// rides the topmost toast host, and a dialog or the player opening or closing moves it to another
// one (a remount). The prompt must come along with what was typed.
const stepPrompt = createStore<string | null>(null);
/** The step prompt's text while it is open, else null. */
export const useStepPrompt = (): string | null => stepPrompt.use();
export const setStepPrompt = (text: string | null): void => stepPrompt.set(text);
// Other tabs of this browser: a Start or a Stop in one is news to the rest, which otherwise learn of
// it only at their next `me` poll — and meanwhile record into a session that has ended.
const CHANNEL = "siftlode.debugSession";
let channel: BroadcastChannel | null | undefined;
function tabs(): BroadcastChannel | null {
if (channel === undefined)
channel = typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(CHANNEL);
return channel;
}
/** Tell the other tabs the recording started or stopped. */
export const announceSessionChange = (): void => tabs()?.postMessage("changed");
/** Run `fn` when another tab started or stopped a recording. */
export function onSessionChangeElsewhere(fn: () => void): () => void {
const c = tabs();
if (!c) return () => {};
const handler = () => fn();
c.addEventListener("message", handler);
return () => c.removeEventListener("message", handler);
}
const composing = createStore<string | null>(null);
/** Open the issue composer for session `id` — after Stop, when the cap ends it, from Settings. */
export const openComposer = (id: string): void => composing.set(id);
@@ -131,6 +131,16 @@ describe("a debug session applied to the tab", () => {
expect(codes).toEqual(expect.arrayContaining(["ui.before", "ui.after"]));
});
it("its last seconds are sent before its cap, while the server still tags them", async () => {
const d = await fresh();
d.applyDiagnostics(BASE, recording("DEBUG", 1), () => {});
await vi.advanceTimersByTimeAsync(57_000); // the regular flushes ran; the next is past the cap
d.log.info("debug.step", "the last step");
await vi.advanceTimersByTimeAsync(2_000); // 59 s: the cap is still a second away
const step = flat().find((e) => e.event === "debug.step");
expect(step?.debug_session_id).toBe("0123456789abcdef");
});
it("flushNow sends what is waiting at once — before Stop closes the session", async () => {
const d = await fresh();
d.applyDiagnostics(BASE, recording("DEBUG"), () => {});
+10
View File
@@ -52,6 +52,10 @@ const DETAIL = { INFO: 0, DEBUG: 1, TRACE: 2 } as const;
let applied: string | null = null;
let lapseTimer: number | null = null;
// A recording's last seconds are sent BEFORE its cap: at the cap the server stops tagging (or, at the
// base level, taking) them, and the regular flush could be up to FLUSH_MS behind.
const CAP_FLUSH_LEAD_MS = 1_500;
let capFlushTimer: number | null = null;
let onLapsed: (() => void) | null = null;
let lastState: DiagnosticsState | null | undefined = null;
let lastSession: DebugSession | null | undefined = null;
@@ -79,6 +83,8 @@ export function applyDiagnostics(
applied = key;
if (lapseTimer !== null) window.clearTimeout(lapseTimer);
lapseTimer = null;
if (capFlushTimer !== null) window.clearTimeout(capFlushTimer);
capFlushTimer = null;
if (level === "INFO") {
stopStreaming();
setLevel("INFO");
@@ -99,6 +105,10 @@ export function applyDiagnostics(
const ends = [until, live?.until].filter((t): t is string => !!t).map((t) => Date.parse(t));
const wait = Math.min(Math.min(...ends) - Date.now(), 2 ** 31 - 1);
lapseTimer = window.setTimeout(lapse, wait);
if (live) {
const lead = Date.parse(live.until) - CAP_FLUSH_LEAD_MS - Date.now();
if (lead > 0 && lead < wait) capFlushTimer = window.setTimeout(() => void flush(), lead);
}
}
/** Something in force ran out: apply what is left of it (the clock decides), then let `me` catch up. */
+3
View File
@@ -100,6 +100,9 @@ export const qk = {
debugSession: (id: string) => ["debug-sessions", id] as const,
debugSessionEvents: (id: string) => ["debug-sessions", id, "events"] as const,
debugScreenshots: (id: string) => ["debug-sessions", id, "screenshots"] as const,
// The tracker's rendering of a body: the text is the key (typing pauses, then it renders again).
debugSessionPreview: (id: string, text: string) =>
["debug-sessions", id, "preview", text] as const,
adminDownloads: () => ["admin-downloads"] as const,
adminQuota: (days: number) => ["admin-quota", days] as const,
demoWhitelist: () => ["demo-whitelist"] as const,