diff --git a/.github/actions/doc-binaries/action.yml b/.github/actions/doc-binaries/action.yml new file mode 100644 index 00000000..b3f3791f --- /dev/null +++ b/.github/actions/doc-binaries/action.yml @@ -0,0 +1,21 @@ +name: Install doc-engine binaries +description: >- + Install the curl-fetched doc-engine binaries (pandoc + pdfcpu) that the + document tools need. LibreOffice comes from apt separately. Versions match + ci.yml and docker/Dockerfile; keep the three in sync. +runs: + using: composite + steps: + - name: Install Pandoc 3.10 (sandboxed DOCX/EPUB) + shell: bash + run: | + curl -fsSL https://github.com/jgm/pandoc/releases/download/3.10/pandoc-3.10-1-amd64.deb -o /tmp/pandoc.deb + sudo apt-get install -y --no-install-recommends /tmp/pandoc.deb + pandoc --version + - name: Install pdfcpu (doc-engine PDF layout binary; matches docker/Dockerfile v0.13.0) + shell: bash + run: | + curl -fsSL https://github.com/pdfcpu/pdfcpu/releases/download/v0.13.0/pdfcpu_0.13.0_Linux_x86_64.tar.xz -o /tmp/pdfcpu.tar.xz + tar -xJf /tmp/pdfcpu.tar.xz -C /tmp + sudo install "$(find /tmp -type f -name pdfcpu | head -1)" /usr/local/bin/pdfcpu + pdfcpu version diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index c692656c..43a63d87 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -12,7 +12,7 @@ env: # Keep in sync with ci.yml's install step. tesseract-ocr + language packs are # required by the built-in Fast OCR tier: without them the OCR integration # tests throw "spawn tesseract ENOENT" and fail the coverage/matrix jobs. - SYSTEM_DEPS: libheif-examples libheif-plugin-x265 libheif-plugin-libde265 libimage-exiftool-perl libraw-bin imagemagick ghostscript libjxl-tools libopenjp2-tools ffmpeg qpdf tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-fra tesseract-ocr-spa tesseract-ocr-chi-sim tesseract-ocr-jpn + SYSTEM_DEPS: libheif-examples libheif-plugin-x265 libheif-plugin-libde265 libimage-exiftool-perl libraw-bin imagemagick ghostscript libjxl-tools libopenjp2-tools ffmpeg qpdf libreoffice-calc libreoffice-impress libreoffice-writer tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-fra tesseract-ocr-spa tesseract-ocr-chi-sim tesseract-ocr-jpn # See ci.yml: ryuk's Docker Hub pull is a recurring flake source; disable the reaper # (tests/global-setup.ts stops its containers explicitly; runners are ephemeral). TESTCONTAINERS_RYUK_DISABLED: "true" @@ -366,6 +366,7 @@ jobs: sudo sed -i 's/ string | null; + isContentEditable?: boolean; + tagName?: string; + }; + if (typeof node.getAttribute !== "function") return false; + if (node.getAttribute("tabindex") !== "-1") return false; + if (node.isContentEditable) return false; + return !/^(INPUT|TEXTAREA|SELECT|BUTTON|A|AUDIO|VIDEO|IFRAME|SUMMARY)$/.test(node.tagName ?? ""); +} + /** Is this search box actually available to the user right now? */ export function isSearchBoxTypeable(input: TypeToSearchTarget, doc: TypeToSearchDocument): boolean { // jsdom implements neither layout nor elementFromPoint. With no real @@ -79,7 +98,13 @@ export function isSearchBoxTypeable(input: TypeToSearchTarget, doc: TypeToSearch // keyboard navigation intact. Anything the user tabbed to, and any focused // input, textarea or contenteditable, is the activeElement, so this one check // replaces a separate "is the target editable" test. - if (doc.activeElement !== doc.body && doc.activeElement !== null) return false; + if ( + doc.activeElement !== doc.body && + doc.activeElement !== null && + !isProgrammaticReadingFocus(doc.activeElement) + ) { + return false; + } const rect = input.getBoundingClientRect(); // A hidden element measures zero, and so does everything in a DOM without diff --git a/tests/e2e/gui-settings-general.spec.ts b/tests/e2e/gui-settings-general.spec.ts index f9666f61..a49d9b48 100644 --- a/tests/e2e/gui-settings-general.spec.ts +++ b/tests/e2e/gui-settings-general.spec.ts @@ -868,12 +868,15 @@ test.describe("GUI Settings - System Settings (extended)", () => { await expect(page.getByText("Login Attempt Limit")).toBeVisible(); const persistedValue = await page.locator("input[type='number']").nth(1).inputValue(); - expect(persistedValue).toBe(testValue); - // Restore original value - await page.locator("input[type='number']").nth(1).fill(originalValue); - await page.getByRole("button", { name: /save settings/i }).click(); - await expect(page.getByText("Settings saved.")).toBeVisible({ timeout: 5_000 }); + // Restore through the API to the e2e webServer's real limit before + // asserting. "Restoring" the input's displayed original writes the + // client-side "5" fallback into the DB when no row existed, which overrides + // LOGIN_ATTEMPT_LIMIT=100000 and 429s every later fresh admin login, + // cascading through the whole serial bucket. + const restore = await putSettings(page, { loginAttemptLimit: "100000" }); + expect(restore.ok).toBeTruthy(); + expect(persistedValue).toBe(testValue); }); test("changed Max File Age persists after dialog re-open", async ({ loggedInPage: page }) => { diff --git a/tests/helpers/generated-case-accounting.ts b/tests/helpers/generated-case-accounting.ts index bc747d1a..c798f284 100644 --- a/tests/helpers/generated-case-accounting.ts +++ b/tests/helpers/generated-case-accounting.ts @@ -49,15 +49,19 @@ export function isEngineUnavailableResponse(statusCode: number, body: string): b * The same gap seen from the worker instead of the route. * * A tool whose input needs no probing, images-to-video being the obvious one, - * is admitted normally and only discovers the missing engine when ffmpeg is - * spawned. Match the engine's own "not found" wording, which it raises before - * spawning anything. A real crash carries an exit code and stderr and has to - * stay a failure. + * is admitted normally and only discovers the missing engine when it is + * spawned. Match each engine's own "not found" wording, which it raises before + * spawning anything. The lean docker test image ships without pdfcpu, + * LibreOffice (soffice), and pandoc as well as ffmpeg, so all four gate the + * same way. A real crash carries an exit code and stderr and has to stay a + * failure. */ export function isEngineUnavailableFailure(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error ?? ""); - if (/\b(ffmpeg|ffprobe)\b[^"]*?\bbinary not found\b/i.test(message)) return true; - return /spawn\s+\S*(ffmpeg|ffprobe)\S*\s+ENOENT/i.test(message); + if (/\b(ffmpeg|ffprobe|pdfcpu|soffice|pandoc)\b[^"]*?\bbinary not found\b/i.test(message)) { + return true; + } + return /spawn\s+\S*(ffmpeg|ffprobe|pdfcpu|soffice|pandoc)\S*\s+ENOENT/i.test(message); } export type GeneratedSkipCategory = (typeof GENERATED_SKIP_CATEGORIES)[number]; diff --git a/tests/unit/helpers/generated-case-accounting.test.ts b/tests/unit/helpers/generated-case-accounting.test.ts index 79bc7e87..3043c8d3 100644 --- a/tests/unit/helpers/generated-case-accounting.test.ts +++ b/tests/unit/helpers/generated-case-accounting.test.ts @@ -2,8 +2,35 @@ import { describe, expect, it } from "vitest"; import { featureUnavailableDisposition, GeneratedCaseAccounting, + isEngineUnavailableFailure, } from "../../helpers/generated-case-accounting.js"; +describe("isEngineUnavailableFailure", () => { + // The lean docker test image skips binary-gated tools by design, so every + // spawn engine gates the same way ffmpeg does: a missing binary is a skip, + // not a product failure. A real crash (exit code + stderr) still fails. + it.each([ + "ffmpeg binary not found (set FFMPEG_PATH or install ffmpeg)", + "pdfcpu binary not found (set PDFCPU_PATH or install pdfcpu)", + "soffice binary not found (set SOFFICE_PATH or install LibreOffice)", + ])("treats a missing-binary message as engine-unavailable: %s", (message) => { + expect(isEngineUnavailableFailure(new Error(message))).toBe(true); + }); + + it.each(["spawn pandoc ENOENT", "spawn /usr/local/bin/pdfcpu ENOENT", "spawn soffice ENOENT"])( + "treats a spawn ENOENT for a known engine as engine-unavailable: %s", + (message) => { + expect(isEngineUnavailableFailure(new Error(message))).toBe(true); + }, + ); + + it("keeps a real processing crash a failure", () => { + expect( + isEngineUnavailableFailure(new Error("pdfcpu exited with code 1: invalid page range")), + ).toBe(false); + }); +}); + describe("GeneratedCaseAccounting", () => { it("fails a tool that executes no generated cases", () => { const accounting = new GeneratedCaseAccounting("resize"); diff --git a/tests/unit/helpers/generated-harness-contract.test.ts b/tests/unit/helpers/generated-harness-contract.test.ts index dcdc7cde..6b7d3ca1 100644 --- a/tests/unit/helpers/generated-harness-contract.test.ts +++ b/tests/unit/helpers/generated-harness-contract.test.ts @@ -70,12 +70,16 @@ describe("generated QA harness contract", () => { expect(text).not.toContain("describe.skip("); }); - it("the actual nightly extended lane requires installed AI features", () => { + it("the nightly extended lane does not require AI features it never installs", () => { + // Strict required-AI mode (absence of a bundle is a failure) is only valid + // on a host with bundles installed, which is the release-QA fleet. The + // extended-matrix lane has no bundle-install step, so requiring them there + // 501s every installed-contract case on a bundle-less runner. const workflow = readFileSync(join(process.cwd(), ".github/workflows/nightly.yml"), "utf8"); const extendedLane = workflow.slice( workflow.indexOf(" extended-matrix:"), workflow.indexOf(" api-fuzz:"), ); - expect(extendedLane).toContain('REQUIRE_AI_FEATURES: "1"'); + expect(extendedLane).not.toContain("REQUIRE_AI_FEATURES"); }); }); diff --git a/tests/unit/shared/type-to-search.test.ts b/tests/unit/shared/type-to-search.test.ts index b2450ad3..9646fc04 100644 --- a/tests/unit/shared/type-to-search.test.ts +++ b/tests/unit/shared/type-to-search.test.ts @@ -225,6 +225,45 @@ describe("isSearchBoxTypeable", () => { expect(isSearchBoxTypeable(input, d)).toBe(true); }); + // A route announcer parks focus on a heading (tabindex="-1") to move the + // screen-reader reading position after a client-side navigation. That focus + // is programmatic and non-editable, so type-to-search must still work. + it("accepts when a route-announcer heading holds the reading position", () => { + const input = box(); + const heading = { + tagName: "H1", + isContentEditable: false, + getAttribute: (name: string) => (name === "tabindex" ? "-1" : null), + }; + const d = doc({ activeElement: heading, elementFromPoint: () => input }); + + expect(isSearchBoxTypeable(input, d)).toBe(true); + }); + + it("still rejects a real focused input even when it has tabindex -1", () => { + const focused = { + tagName: "INPUT", + isContentEditable: false, + getAttribute: (name: string) => (name === "tabindex" ? "-1" : null), + }; + const input = box(); + const d = doc({ activeElement: focused, elementFromPoint: () => input }); + + expect(isSearchBoxTypeable(input, d)).toBe(false); + }); + + it("still rejects a contenteditable region even at tabindex -1", () => { + const editable = { + tagName: "DIV", + isContentEditable: true, + getAttribute: (name: string) => (name === "tabindex" ? "-1" : null), + }; + const input = box(); + const d = doc({ activeElement: editable, elementFromPoint: () => input }); + + expect(isSearchBoxTypeable(input, d)).toBe(false); + }); + it("rejects a zero-width box, which is how a hidden element measures", () => { const input = box([], { width: 0 }); const d = doc({ elementFromPoint: () => input });