ci(nightly): repair five job classes broken by the #649 QA hardening (#695)

Nightly has been red since 07-28; per-PR CI and main are green. Two investigations traced all five failing classes to #649: extended-matrix required AI bundles it never installs (removed), nightly SYSTEM_DEPS drifted from ci.yml (added libreoffice + a doc-binaries composite for pandoc/pdfcpu), the generated-case classifier only skipped ffmpeg (widened to pdfcpu/soffice/pandoc + excluded repo-audit specs from the lean docker image), a settings spec capped loginAttemptLimit and 429-cascaded the serial bucket (restore via API), and type-to-search refused keystrokes under a route announcer's programmatic focus (guard added). A nightly dispatch on the branch confirmed all five classes green.
This commit is contained in:
SnapOtter
2026-07-31 02:09:39 +08:00
committed by GitHub
parent 44b1aa9767
commit 1b41da7615
9 changed files with 167 additions and 17 deletions
+21
View File
@@ -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
+6 -2
View File
@@ -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/<policy domain="coder" rights="none" pattern="EPS"/<policy domain="coder" rights="read" pattern="EPS"/' "$POLICY_FILE"
sudo sed -i 's/<policy domain="coder" rights="none" pattern="PS"/<policy domain="coder" rights="read" pattern="PS"/' "$POLICY_FILE"
fi
- uses: ./.github/actions/doc-binaries
- uses: ./.github/actions/setup
- name: Pre-pull testcontainer images (retry transient Docker Hub errors)
run: |
@@ -384,7 +385,9 @@ jobs:
FULL_MATRIX: "1"
FUZZ: "1"
FUZZ_RUNS: "50"
REQUIRE_AI_FEATURES: "1"
# This lane installs no AI bundles, so it must not run the strict
# required-AI contract (that 501s every installed-contract case). The
# strict contract runs on the release-QA fleet, which has bundles.
# Full-matrix tests iterate every format x tool; with 4 forks the heavy
# media conversions starve and hit the 30s default. Fewer forks (more
# CPU each) plus a generous timeout keeps them from flaking.
@@ -499,6 +502,7 @@ jobs:
sudo sed -i 's/<policy domain="coder" rights="none" pattern="EPS"/<policy domain="coder" rights="read" pattern="EPS"/' "$POLICY_FILE"
sudo sed -i 's/<policy domain="coder" rights="none" pattern="PS"/<policy domain="coder" rights="read" pattern="PS"/' "$POLICY_FILE"
fi
- uses: ./.github/actions/doc-binaries
- uses: ./.github/actions/setup
- name: Run tests with coverage
run: pnpm vitest run --coverage tests/unit/ tests/integration/
+24 -1
View File
@@ -14,7 +14,30 @@ services:
# skips binary-gated tools (AI models need on-demand bundles, etc.), so it
# cannot meet the host-calibrated coverage thresholds; coverage stays
# enforced on host CI where every tool is present.
command: ["pnpm", "vitest", "run", "--reporter=verbose"]
#
# Repo-audit specs are excluded: they audit the git checkout and dev host,
# not the running product. Dockerfile.test.dockerignore prunes *.md and
# .git, and /tmp is a noexec tmpfs, so in this image they can only report
# the build context's own stripping (missing DOCKERHUB.md / CONTRIBUTING.md
# / third-party notices, no `git ls-files`, a non-executable fake binary).
# Host CI runs all of them on every PR.
command:
[
"pnpm",
"vitest",
"run",
"--reporter=verbose",
"--exclude",
"**/tests/unit/infra/public-tool-counts.test.ts",
"--exclude",
"**/tests/unit/infra/lint-coverage.test.ts",
"--exclude",
"**/tests/unit/infra/docs-dev-ports.test.ts",
"--exclude",
"**/tests/unit/scripts/check-production-licenses.test.ts",
"--exclude",
"**/tests/unit/helpers/run-generated-tool.test.ts",
]
environment:
- NODE_ENV=test
- AUTH_ENABLED=true
+26 -1
View File
@@ -67,6 +67,25 @@ export function isTypeToSearchKey(event: TypeToSearchKeyEvent): boolean {
return true;
}
/**
* A route announcer parks focus on a heading or main region with tabindex="-1"
* to move the screen-reader reading position after a client-side navigation.
* That focus is programmatic, unreachable by tabbing, and non-editable, so it
* must not disable type-to-search the way a real focused control does.
*/
function isProgrammaticReadingFocus(el: unknown): boolean {
if (typeof el !== "object" || el === null) return false;
const node = el as {
getAttribute?: (name: string) => 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
+8 -5
View File
@@ -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 }) => {
+10 -6
View File
@@ -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];
@@ -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");
@@ -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");
});
});
+39
View File
@@ -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 });