fix(diagnostics): deep-review round 3 — a keyboard owner that is not a Tab trap
Round 2 made the step prompt a blocking layer so that `j` and the `g` chords would not reach the page behind it. The dialog it sits in gave up its Tab trap because of that, while the prompt's own replacement trap covered only its own two controls — so Tab from anywhere else in the dialog (the bar's Screenshot or Stop, the dialog's own buttons) walked out behind the scrim. `layerStack` now separates the two questions it had folded into one flag: a layer can own the keyboard and still leave the Tab trap to the dialog below it. The mirror case, a dialog opening over the prompt, falls out of the same split. A send's claim is now a lease with an owner. Every progress report re-stamps it, so a send slower than the lease cannot lose it while it is still running, and only the send that took a claim can give it back. The issue_create audit row is staged inside the send and rides the same checkpoint as `issue_url`, so the trail cannot be missing for an issue that exists. The attachment-name prefix has one definition, shared by the producer and both readers. A running send is reported to the UI. A reload or a second tab used to render it exactly like a send that STOPPED, and offered a retry the server was certain to refuse; the composer now says the report is being sent, holds the draft, and puts both the retry and Create issue out. The past-sessions list says the same for the same reason — it is the list a user opens to check on a send, and both "draft" and "half sent" describe one that died. `/preview` and the retry path got a pace of their own, and the per-user byte cap took the user-scoped lock that makes it a cap instead of a guess. Verified in a real browser on localdev: Tab cycles the whole dialog with the prompt open, the prompt still swallows the page's keys, and the issue link is focusable where the composer puts it. All 24 regression tests mutation-checked.
This commit is contained in:
@@ -25,7 +25,8 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const { order, session, startDebugSession, stopDebugSession, hold } = vi.hoisted(() => {
|
||||
const { order, session, startDebugSession, stopDebugSession, hold, past } = vi.hoisted(() => {
|
||||
const past: { items: unknown[] } = { items: [] };
|
||||
// A Stop held open until released, when a test needs it in flight.
|
||||
const hold = { stop: null as Promise<void> | null };
|
||||
const order: string[] = [];
|
||||
@@ -39,6 +40,7 @@ const { order, session, startDebugSession, stopDebugSession, hold } = vi.hoisted
|
||||
issue_url: null,
|
||||
pending_files: [],
|
||||
images_pending: false,
|
||||
sending: false,
|
||||
screenshots: 0,
|
||||
draft: null,
|
||||
...over,
|
||||
@@ -54,11 +56,19 @@ const { order, session, startDebugSession, stopDebugSession, hold } = vi.hoisted
|
||||
session: session({ id, ended_at: new Date().toISOString(), ended_reason: "stopped" }),
|
||||
};
|
||||
});
|
||||
return { order, session, startDebugSession, stopDebugSession, hold };
|
||||
return { order, session, startDebugSession, stopDebugSession, hold, past };
|
||||
});
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return { ...actual, api: { ...actual.api, startDebugSession, stopDebugSession } };
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
startDebugSession,
|
||||
stopDebugSession,
|
||||
debugSessions: () => Promise.resolve(past),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../lib/diagnostics", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/diagnostics")>();
|
||||
@@ -71,7 +81,7 @@ vi.mock("../lib/diagnostics", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import DebugRecorder from "./DebugRecorder";
|
||||
import DebugRecorder, { RecorderSettings } from "./DebugRecorder";
|
||||
import Modal from "./ui/Modal";
|
||||
import Toaster from "./ui/Toaster";
|
||||
import {
|
||||
@@ -80,7 +90,7 @@ import {
|
||||
setStepPrompt,
|
||||
useComposerSession,
|
||||
} from "../lib/debugRecorder";
|
||||
import { anyBlockingLayer } from "../lib/layerStack";
|
||||
import { anyBlockingLayer, anyBlockingLayerAbove, pushLayer, removeLayer } from "../lib/layerStack";
|
||||
import { events } from "../lib/log";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -217,15 +227,22 @@ describe("the recorder pill", () => {
|
||||
expect(unhandled).toBe(false);
|
||||
});
|
||||
|
||||
it("the step prompt owns the keyboard: the page's keys stay quiet and Tab stays in it", () => {
|
||||
it("the step prompt quiets the page's keys and leaves the Tab to the dialog it sits in", () => {
|
||||
mount({ debug_session: session() });
|
||||
expect(anyBlockingLayer()).toBe(false);
|
||||
// The pill docks inside whatever dialog is open; stand one in for it, under the prompt.
|
||||
const dialog = pushLayer();
|
||||
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" }));
|
||||
// No dialog under it here: the prompt itself is what keeps `j`/`g h` off the page behind.
|
||||
|
||||
// The prompt's field has the keyboard, so `j` and the `g` chords stay off the page behind...
|
||||
expect(anyBlockingLayer()).toBe(true);
|
||||
// ...while the dialog around it keeps its focus trap. The prompt cannot be that trap: its two
|
||||
// controls are two of the dialog's many, and taking it left the rest of them untrapped, with
|
||||
// Tab walking out into the page behind the scrim.
|
||||
expect(anyBlockingLayerAbove(dialog)).toBe(false);
|
||||
// And the quiet is the prompt's own doing, not the stand-in's: it outlasts the dialog.
|
||||
removeLayer(dialog);
|
||||
expect(anyBlockingLayer()).toBe(true);
|
||||
const mark = screen.getByRole("button", { name: "recorder.pill.mark" });
|
||||
mark.focus();
|
||||
expect(fireEvent.keyDown(mark, { key: "Tab" })).toBe(false); // trapped in the prompt
|
||||
});
|
||||
|
||||
it("the step prompt keeps what was typed when the pill moves to a dialog and back", () => {
|
||||
@@ -293,3 +310,72 @@ describe("the start dialog", () => {
|
||||
expect(screen.queryByText("recorder.start.title")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the past sessions a user goes back to check", () => {
|
||||
function settings() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), {
|
||||
id: 1,
|
||||
is_demo: false,
|
||||
preferences: {},
|
||||
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
|
||||
debug_session: null,
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<RecorderSettings />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
it("does not call a half-delivered report sent", async () => {
|
||||
// An issue that still owes its logs, or an edit that shows its screenshots, is not done. This
|
||||
// list is where a user checks back, and a green "Sent" here is how one gets forgotten.
|
||||
const over = { ended_at: new Date().toISOString(), ended_reason: "stopped" as const };
|
||||
past.items = [
|
||||
session({
|
||||
...over,
|
||||
id: "aaaaaaaaaaaaaaa1",
|
||||
issue_url: "https://git.example/o/r/issues/7",
|
||||
pending_files: ["client.log"],
|
||||
}),
|
||||
session({
|
||||
...over,
|
||||
id: "aaaaaaaaaaaaaaa2",
|
||||
issue_url: "https://git.example/o/r/issues/8",
|
||||
images_pending: true,
|
||||
}),
|
||||
session({ ...over, id: "aaaaaaaaaaaaaaa3", issue_url: "https://git.example/o/r/issues/9" }),
|
||||
];
|
||||
settings();
|
||||
|
||||
expect(await screen.findAllByText("recorder.history.partial")).toHaveLength(2);
|
||||
expect(screen.getAllByText("recorder.history.sent")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("says a send is still running rather than naming what it has got to so far", async () => {
|
||||
// This is the list a user opens TO CHECK on a send. Its two honest-looking words are both
|
||||
// wrong for a running one: "draft" before it has an issue, "half sent" after.
|
||||
const over = { ended_at: new Date().toISOString(), ended_reason: "stopped" as const };
|
||||
past.items = [
|
||||
session({
|
||||
...over,
|
||||
id: "bbbbbbbbbbbbbbb1",
|
||||
sending: true,
|
||||
draft: { title: "t", body: "b", excluded: [], form: {} },
|
||||
}),
|
||||
session({
|
||||
...over,
|
||||
id: "bbbbbbbbbbbbbbb2",
|
||||
sending: true,
|
||||
issue_url: "https://git.example/o/r/issues/7",
|
||||
pending_files: ["client.log"],
|
||||
}),
|
||||
];
|
||||
settings();
|
||||
|
||||
expect(await screen.findAllByText("recorder.history.sending")).toHaveLength(2);
|
||||
expect(screen.queryByText("recorder.history.draft")).toBeNull();
|
||||
expect(screen.queryByText("recorder.history.partial")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user