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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"), () => {});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user