Files
siftlode/frontend/src/lib/captureTab.dom.test.ts
T
peter 134ec9a68e 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.
2026-09-20 00:09:52 +02:00

56 lines
2.0 KiB
TypeScript

// @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();
});
});