test(a11y): reproduce native keyboard file-picker failures

This commit is contained in:
cesnimda
2026-10-10 21:45:22 +02:00
parent f55c761ac2
commit bab2730d9b
2 changed files with 104 additions and 0 deletions
@@ -0,0 +1,11 @@
# P5-15-04-A11Y-01 — Native file-picker keyboard repair
## Reproduction checkpoint
From `f55c761ac229a69357a1c5b68a06e837cfa31ec0`, the unchanged native audit again reports six Enter/Space failures for Upload, Import JSON and Convert CSV to Import JSON; the three pointer comparisons succeed. The native suite's other failure is the separately owned CV source route. No files were uploaded by these chooser observations.
The label-only MUI buttons have no click handler. MUI keyboard activation invokes a nonnative button's handler; it does not emulate the browser's label-to-input default activation. The same pattern exists in Add Job's four attachment buckets. It is not an API, permissions or file-validation failure.
`src/file-picker-activation.test.tsx` adds eight real-component cases: seven keyboard/input activation boundaries (all fail with expected one input click/actual zero), plus file/Job association, uploading disabled state and post-upload selection reset. The latter also exposes that a label is not a native disabled button. Earlier test-fixture label/step mistakes were corrected before recording the final RED; they are not application defects.
Affected contracts: PRES-W08 attachments and PRES-J10 import/export; Add Job same-pattern caller is covered without changing its domain workflow. Whole groups remain FAIL at this reproduction checkpoint. No implementation correction or fresh full-suite claim is made here. Production remains NO-GO; human screen-reader/device acceptance remains incomplete.
@@ -0,0 +1,93 @@
import React from "react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Attachments from "./components/Attachments";
import ImportExportJobs from "./components/ImportExportJobs";
import AddJobModal from "./components/AddJobModal";
import { ConfirmProvider } from "./confirm";
import { PromptProvider } from "./prompt";
import { I18nProvider } from "./i18n/I18nProvider";
import { ToastProvider } from "./toast";
import { api } from "./api";
// These tests exercise file selection; the unrelated date-picker adapter is ESM.
jest.mock("@mui/x-date-pickers/DatePicker", () => ({ DatePicker: ({ label }: { label: string }) => <div>{label}</div> }));
const mockedApi = api as jest.Mocked<typeof api>;
function renderPicker(component: React.ReactNode) {
return render(<ToastProvider><I18nProvider><ConfirmProvider><PromptProvider>{component}</PromptProvider></ConfirmProvider></I18nProvider></ToastProvider>);
}
async function expectNativeInputActivation(button: HTMLElement, input: HTMLInputElement) {
const activated = jest.fn();
input.addEventListener("click", activated);
act(() => button.focus());
await userEvent.keyboard("{Enter}");
expect(activated).toHaveBeenCalledTimes(1);
await userEvent.keyboard(" ");
expect(activated).toHaveBeenCalledTimes(2);
userEvent.click(button);
expect(activated).toHaveBeenCalledTimes(3);
expect(button).toHaveFocus();
}
beforeEach(() => {
window.localStorage.clear();
jest.clearAllMocks();
});
test("attachment Upload activates the same native input from Enter, Space and pointer", async () => {
mockedApi.get.mockResolvedValue({ data: [] });
const { container } = renderPicker(<Attachments jobId={42} />);
await screen.findByText(/No attachments yet\./);
await expectNativeInputActivation(screen.getByRole("button", { name: "Upload" }), container.querySelector<HTMLInputElement>('input[type="file"]')!);
expect(mockedApi.post).not.toHaveBeenCalled();
});
test.each([
["Import JSON", "application/json"],
["Convert CSV to Import JSON", ".csv,text/csv"],
])("%s activates its own native input from Enter, Space and pointer", async (name, accept) => {
const { container } = renderPicker(<ImportExportJobs />);
await expectNativeInputActivation(screen.getByRole("button", { name }), container.querySelector<HTMLInputElement>(`input[accept="${accept}"]`)!);
expect(mockedApi.post).not.toHaveBeenCalled();
});
test.each([0, 1, 2, 3])("Add Job attachment bucket %s retains keyboard selection and distinct pending files", async (bucket) => {
mockedApi.post.mockImplementation((url) => Promise.resolve({ data: url === "/jobimport/preview" ? { success: true, title: "Supported role", company: "Fixture company", description: "Known description" } : {} }));
renderPicker(<AddJobModal open initialUrl="https://example.test/jobs/1" onClose={() => {}} onCreated={() => {}} />);
await screen.findByDisplayValue("Supported role");
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
for (let step = 0; step < bucket; step += 1) fireEvent.click(screen.getByRole("button", { name: "Skip and continue" }));
const input = document.querySelector<HTMLInputElement>('input[type="file"]')!;
expect(input.multiple).toBe(true);
expect(input.accept).toContain(".pdf,.doc,.docx,.txt,.md");
await expectNativeInputActivation(screen.getByRole("button", { name: "Choose files" }), input);
fireEvent.change(input, { target: { files: [new File(["fixture"], `bucket-${bucket}.txt`, { type: "text/plain" })] } });
expect(screen.getByText(`bucket-${bucket}.txt`)).toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalledWith("/attachments", expect.anything(), expect.anything());
});
test("attachment selection retains files and job ownership, disables activation while uploading and clears selection after completion", async () => {
mockedApi.get.mockResolvedValue({ data: [] });
let finishUpload!: (value: { data: object }) => void;
mockedApi.post.mockImplementationOnce(() => new Promise((resolve) => { finishUpload = resolve; }));
const { container } = renderPicker(<Attachments jobId={42} />);
await screen.findByText(/No attachments yet\./);
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!;
const files = [new File(["Known CV"], "cv.pdf", { type: "application/pdf" }), new File(["Søknad"], "søknad.txt", { type: "text/plain" })];
fireEvent.change(input, { target: { files } });
const uploading = await screen.findByRole("button", { name: "Uploading..." });
expect(uploading).toBeDisabled();
const body = mockedApi.post.mock.calls[0][1] as FormData;
expect(body.get("jobId")).toBe("42");
expect(body.getAll("files")).toEqual(files);
const activated = jest.fn();
input.addEventListener("click", activated);
userEvent.click(uploading);
expect(activated).not.toHaveBeenCalled();
finishUpload({ data: {} });
await waitFor(() => expect(screen.getByRole("button", { name: "Upload" })).toBeEnabled());
expect(input.value).toBe("");
});