mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user