Files
SnapOtter/tests/unit/web/usage-survey-overlay.test.tsx
SnapOtterandGitHub d10d0f544f fix: release QA hardening across processing, media, security, and CI gates (#649)
A release-readiness QA pass over the whole product. The commits split into
defects a user would hit and gates that were reporting green while measuring
nothing.

## Fixes that change behaviour

Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so
request.ip came from a client-set header and a forged X-Forwarded-For got past
the login limiter. The default is now a private-network trust list.

A transient Postgres outage stranded in-flight jobs, leaving finished output on
disk with no row pointing at it. A reconciler now resolves those rows and adopts
the bytes rather than dropping the work.

A Redis connection that moved to a new address wedged every read-blocked
consumer, so completions stopped signalling while health still answered 200.
Socket timeouts plus subscriber pings recover it.

Installing more than one AI bundle left the shared venv multi-versioned and
silently broke three tools. The installer now reconciles distributions to one
version each.

Converting an image to JXL at quality 1 through 4 returned a 500, because
libjxl 0.7 rejects the distance those values compute. The quality is floored at
what the encoder honours. A missing ffmpeg was also reported to the user as a
corrupt upload; it now says the engine is unavailable.

RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at
0.22.2, and the release scan was split so it can fail on an unfixed critical
instead of hiding it behind ignore-unfixed.

## Gates that could not fail

Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs
build; coverage discarded its whole report on any failing test; the lint gate
skipped root tests, scripts, and two workspaces; and several generated matrices
counted a host missing ffmpeg as a passing tool. Each now measures what it
claims.

Full evidence and the outstanding release items are tracked locally and are not
part of this branch.
2026-07-27 15:37:30 +08:00

368 lines
15 KiB
TypeScript

// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
const submitFeedback = vi.hoisted(() => vi.fn().mockResolvedValue({ ok: true, accepted: true }));
const trackFeedbackPromptShown = vi.hoisted(() => vi.fn());
const trackFeedbackPromptDismissed = vi.hoisted(() => vi.fn());
const apiGet = vi.hoisted(() => vi.fn());
const apiPut = vi.hoisted(() => vi.fn().mockResolvedValue({}));
const useAuth = vi.hoisted(() => vi.fn());
vi.mock("@/lib/feedback", async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return { ...actual, submitFeedback, trackFeedbackPromptShown, trackFeedbackPromptDismissed };
});
vi.mock("@/lib/api", async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return { ...actual, apiGet, apiPut };
});
vi.mock("@/hooks/use-auth", () => ({ useAuth }));
vi.mock("@/stores/analytics-store", () => ({
useAnalyticsStore: (
selector: (state: { config: { enabled: boolean }; configLoaded: boolean }) => unknown,
) => selector({ config: { enabled: true }, configLoaded: true }),
}));
import { UsageSurveyOverlay } from "@/components/onboarding/usage-survey-overlay";
// The worker writes this marker on the instance's first successful processing.
// The survey is only eligible once it exists; without it the overlay stays hidden.
const PROCESSED = { "onboarding.firstProcessedAt": "2026-01-01T00:00:00Z" };
afterEach(() => {
cleanup();
submitFeedback.mockClear();
trackFeedbackPromptShown.mockClear();
trackFeedbackPromptDismissed.mockClear();
apiGet.mockClear();
apiPut.mockClear();
useAuth.mockReset();
});
function renderOverlay(initialPath = "/") {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<UsageSurveyOverlay />
</MemoryRouter>,
);
}
describe("UsageSurveyOverlay", () => {
it("renders nothing for a non-admin", () => {
useAuth.mockReturnValue({ role: "user", mustChangePassword: false });
renderOverlay();
expect(apiGet).not.toHaveBeenCalled();
expect(screen.queryByText("How are you using SnapOtter?")).toBeNull();
});
it("stays hidden until the instance's first processing has completed", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: {} });
renderOverlay();
await waitFor(() => expect(apiGet).toHaveBeenCalled());
expect(screen.queryByText("How are you using SnapOtter?")).toBeNull();
expect(trackFeedbackPromptShown).not.toHaveBeenCalled();
});
it("renders nothing once already answered or dismissed", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({
settings: { ...PROCESSED, "onboarding.usageSurvey.dismissedAt": "2026-01-01T00:00:00Z" },
});
renderOverlay();
await waitFor(() => expect(apiGet).toHaveBeenCalled());
expect(screen.queryByText("How are you using SnapOtter?")).toBeNull();
});
it("shows the telemetry-blind questions after processing and emits a shown event", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay();
expect(await screen.findByText("How are you using SnapOtter?")).toBeDefined();
expect(screen.getByRole("radio", { name: /Just me/ })).toBeDefined();
expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled();
// Opening ask is one question. The optional three stay collapsed until the
// required one is answered, so the first impression is not a wall of 20.
expect(screen.queryByText("What were you using before?")).toBeNull();
expect(screen.queryByText(/Why self-host it/)).toBeNull();
await waitFor(() => expect(trackFeedbackPromptShown).toHaveBeenCalledWith("onboarding"));
});
it("submits only the usage type when nothing else is picked", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay();
await screen.findByText("How are you using SnapOtter?");
fireEvent.click(screen.getByRole("radio", { name: /Small team/ }));
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => {
expect(submitFeedback).toHaveBeenCalledWith({
source: "onboarding",
surveyId: "onboarding-usage-v1",
promptVariant: "onboarding-card-v1",
usageType: "team_internal",
});
});
expect(apiPut).toHaveBeenCalledWith("/v1/settings", {
"onboarding.usageSurvey.answeredAt": expect.any(String),
});
});
it("includes prior tool, motivation, and discovery source when the admin selects them", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay();
await screen.findByText("How are you using SnapOtter?");
fireEvent.click(screen.getByRole("radio", { name: /Just me/ }));
// Answering the required question reveals the optional follow-ups.
expect(await screen.findByText("What were you using before?")).toBeDefined();
fireEvent.click(screen.getByRole("radio", { name: /Command line/ }));
fireEvent.click(screen.getByRole("radio", { name: /Privacy and data control/ }));
fireEvent.change(screen.getByLabelText(/How did you hear about us/), {
target: { value: "github" },
});
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => {
expect(submitFeedback).toHaveBeenCalledWith({
source: "onboarding",
surveyId: "onboarding-usage-v1",
promptVariant: "onboarding-card-v1",
usageType: "personal",
priorTool: "command_line",
selfHostMotivation: "privacy_control",
discoverySource: "github",
});
});
});
it("dismissing writes the dismiss key, emits a dismissed event, and does not submit", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay();
await screen.findByText("How are you using SnapOtter?");
fireEvent.click(screen.getByRole("button", { name: "Don't ask again" }));
await waitFor(() => {
expect(apiPut).toHaveBeenCalledWith("/v1/settings", {
"onboarding.usageSurvey.dismissedAt": expect.any(String),
});
});
expect(trackFeedbackPromptDismissed).toHaveBeenCalledWith("onboarding", "dont_ask_again");
expect(submitFeedback).not.toHaveBeenCalled();
});
it("does not resubmit feedback if only the settings write failed on the first attempt", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: PROCESSED });
apiPut.mockRejectedValueOnce(new Error("network error")).mockResolvedValueOnce({});
renderOverlay();
await screen.findByText("How are you using SnapOtter?");
fireEvent.click(screen.getByRole("radio", { name: /Just me/ }));
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => expect(apiPut).toHaveBeenCalledTimes(1));
expect(submitFeedback).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => expect(apiPut).toHaveBeenCalledTimes(2));
expect(submitFeedback).toHaveBeenCalledTimes(1);
});
it("stays visible and re-enables Continue if the feedback submission itself fails", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: PROCESSED });
submitFeedback.mockRejectedValueOnce(new Error("network error"));
renderOverlay();
await screen.findByText("How are you using SnapOtter?");
fireEvent.click(screen.getByRole("radio", { name: /Just me/ }));
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => expect(submitFeedback).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Continue" })).not.toBeDisabled(),
);
expect(screen.getByText("How are you using SnapOtter?")).toBeDefined();
expect(apiPut).not.toHaveBeenCalled();
});
it("ignores a second dismiss click while the first write is in flight", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: PROCESSED });
let resolveApiPut: (() => void) | undefined;
apiPut.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveApiPut = () => resolve({});
}),
);
renderOverlay();
await screen.findByText("How are you using SnapOtter?");
const dismissButton = screen.getByRole("button", { name: "Don't ask again" });
fireEvent.click(dismissButton);
fireEvent.click(dismissButton);
fireEvent.click(dismissButton);
resolveApiPut?.();
await waitFor(() => {
expect(apiPut).toHaveBeenCalledWith("/v1/settings", {
"onboarding.usageSurvey.dismissedAt": expect.any(String),
});
});
expect(apiPut).toHaveBeenCalledTimes(1);
});
// The prompt used to be an opaque full-screen takeover with aria-modal, a
// focus trap, and no Escape handler, so the only exits were answering the
// required question or finding a text-xs grey link. These pin the friction fix.
describe("does not block the app", () => {
it("is not a modal and does not cover the screen", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay();
await screen.findByText("How are you using SnapOtter?");
const dialog = screen.getByRole("dialog");
expect(dialog).not.toHaveAttribute("aria-modal", "true");
expect(dialog.className).not.toContain("inset-0");
});
it("closes on Escape and records the dismissal", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay();
await screen.findByText("How are you using SnapOtter?");
await act(async () => {});
// Escape is the one control here whose handler is a document listener
// registered by a passive effect; every other control is an onClick prop
// attached at commit. That makes it the only one that can miss an event
// dispatched too early, and it flaked exactly once on main: dialog fully
// rendered, handler never ran. Locally the listener is always attached by
// the time findByText resolves, so the window only opens under CI
// contention and cannot be reproduced here. Rather than bet on one
// mechanism, retry the dispatch until it lands. handleDismiss guards on
// `busy` and the settings write is idempotent, so repeats are harmless.
await waitFor(() => {
fireEvent.keyDown(document, { key: "Escape" });
expect(apiPut).toHaveBeenCalledWith("/v1/settings", {
"onboarding.usageSurvey.dismissedAt": expect.any(String),
});
});
expect(trackFeedbackPromptDismissed).toHaveBeenCalledWith("onboarding", "close");
expect(submitFeedback).not.toHaveBeenCalled();
});
it("offers a visible close control, not just a faint text link", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay();
await screen.findByText("How are you using SnapOtter?");
fireEvent.click(screen.getByRole("button", { name: "Close feedback dialog" }));
await waitFor(() => {
expect(apiPut).toHaveBeenCalledWith("/v1/settings", {
"onboarding.usageSurvey.dismissedAt": expect.any(String),
});
});
expect(trackFeedbackPromptDismissed).toHaveBeenCalledWith("onboarding", "close");
});
// RouteAnnouncer focuses and announces document.querySelector("h1") on every
// route change. While this prompt's title was an h1 it stole focus the moment
// the card appeared and made every later navigation announce the survey
// instead of the page the user opened.
it("does not claim the page's h1", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay();
const title = await screen.findByText("How are you using SnapOtter?");
expect(title.tagName).not.toBe("H1");
expect(screen.queryByRole("heading", { level: 1 })).toBeNull();
// Still the dialog's accessible name.
expect(screen.getByRole("dialog")).toHaveAttribute("aria-labelledby", title.id);
});
it("can be sent after a single click, without touching the optional questions", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay();
await screen.findByText("How are you using SnapOtter?");
fireEvent.click(screen.getByRole("radio", { name: /Just me/ }));
expect(screen.getByRole("button", { name: "Continue" })).not.toBeDisabled();
});
});
it("renders nothing when the admin must still change their password", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: true });
apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(apiGet).not.toHaveBeenCalled();
expect(screen.queryByText("How are you using SnapOtter?")).toBeNull();
});
it("renders nothing on the change-password route even if mustChangePassword is stale-false", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay("/change-password");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(apiGet).not.toHaveBeenCalled();
expect(screen.queryByText("How are you using SnapOtter?")).toBeNull();
});
it("renders nothing on the privacy policy route", async () => {
useAuth.mockReturnValue({ role: "admin", mustChangePassword: false });
apiGet.mockResolvedValue({ settings: PROCESSED });
renderOverlay("/privacy");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(apiGet).not.toHaveBeenCalled();
expect(screen.queryByText("How are you using SnapOtter?")).toBeNull();
});
});