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.
56 lines
2.0 KiB
TypeScript
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();
|
|
});
|
|
});
|