diff --git a/.claude/policy-brief.md b/.claude/policy-brief.md index 615f41f15..20b0ad83a 100644 --- a/.claude/policy-brief.md +++ b/.claude/policy-brief.md @@ -5,8 +5,8 @@ Repository: `studio-berry/loop`; version: `0.2.0-alpha`; language: `C++20`; mini ## Branches and safety -- Integration: `dev`; release/default: `stable`; topic branches start from `dev`. -- Protected branches: `dev`, `stable`. Do not commit, push, merge, force-push, or rewrite history without approval. +- Integration: `dev`; qualification: `unstable`; release/default: `stable`; topic branches start from `dev`. Promotion: `dev` → `unstable` → `stable`. +- Protected branches: `unstable`, `stable`. Do not commit, push, merge, force-push, or rewrite history without approval. - Keep private data, credentials, logs, scratch plans, and build artifacts outside the repository. Do not edit vendored dependencies unless explicitly scoped. ## Autonomous verification budget diff --git a/.cursor/agent-policy.md b/.cursor/agent-policy.md index 8f9fc0dac..cd0d888b8 100644 --- a/.cursor/agent-policy.md +++ b/.cursor/agent-policy.md @@ -5,8 +5,8 @@ Repository: `studio-berry/loop`; version: `0.2.0-alpha`; language: `C++20`; mini ## Branches and safety -- Integration: `dev`; release/default: `stable`; topic branches start from `dev`. -- Protected branches: `dev`, `stable`. Do not commit, push, merge, force-push, or rewrite history without approval. +- Integration: `dev`; qualification: `unstable`; release/default: `stable`; topic branches start from `dev`. Promotion: `dev` → `unstable` → `stable`. +- Protected branches: `unstable`, `stable`. Do not commit, push, merge, force-push, or rewrite history without approval. - Keep private data, credentials, logs, scratch plans, and build artifacts outside the repository. Do not edit vendored dependencies unless explicitly scoped. ## Autonomous verification budget diff --git a/.gitattributes b/.gitattributes index 21ee04d51..4dd06fd06 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,7 @@ *.sh text eol=lf docs/generated/phase5-widgets-inventory.json text eol=lf docs/generated/phase5-widgets-disposition.json text eol=lf +UnitTests/testdata/interaction-traces/** text eol=lf *.pdf binary *.icc binary Fuzz/corpus/regression/** binary diff --git a/.github/workflows/LinuxInstall.yml b/.github/workflows/LinuxInstall.yml index 9be636972..6e0903800 100644 --- a/.github/workflows/LinuxInstall.yml +++ b/.github/workflows/LinuxInstall.yml @@ -15,10 +15,13 @@ permissions: jobs: build_ubuntu: - runs-on: ubuntu-24.04 + # linuxdeployqt is pinned to a glibc-2.35-compatible build. Keep the + # packaging runner aligned with that oldest-supported deployment tool. + runs-on: ubuntu-22.04 env: VCPKG_OVERLAY_PORTS: ${{ github.workspace }}/loop/vcpkg/overlays/linux:${{ github.workspace }}/loop/vcpkg/overlays/general VCPKG_INSTALLED_DIR: ${{ github.workspace }}/vcpkg_installed + VCPKG_DEFAULT_BINARY_CACHE: ${{ github.workspace }}/vcpkg-binary-cache GNUPGHOME: ${{ github.workspace }}/gnupg QT_QPA_PLATFORM: offscreen @@ -26,7 +29,7 @@ jobs: - name: Install dependencies run: | sudo apt-get update - sudo apt-get install -y libxcb-cursor0 libspeechd2 gnupg2 wget appstream libcups2 libcups2-dev + sudo apt-get install -y libxcb-cursor0 libspeechd2 libfontconfig1-dev gnupg2 wget appstream libcups2 libcups2-dev - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -106,6 +109,7 @@ jobs: - name: 'VCPKG: Set up VCPKG' run: | + mkdir -p "$VCPKG_DEFAULT_BINARY_CACHE" VCPKG_COMMIT="$(python3 - <<'PY' import json with open("loop/vcpkg-configuration.json", encoding="utf-8") as f: @@ -128,7 +132,8 @@ jobs: } ./vcpkg/bootstrap-vcpkg.sh - ./vcpkg integrate install + ./vcpkg/vcpkg integrate install + echo "VCPKG_BINARY_SOURCES=clear;files,$VCPKG_DEFAULT_BINARY_CACHE,readwrite" >> "$GITHUB_ENV" - name: 'VCPKG: Cache vcpkg dependencies' uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 @@ -137,6 +142,7 @@ jobs: ./vcpkg/downloads ./vcpkg/packages ./vcpkg_installed + ./vcpkg-binary-cache key: ${{ runner.os }}-vcpkg-v2-${{ hashFiles('**/vcpkg.json', '**/vcpkg-configuration.json') }} restore-keys: | ${{ runner.os }}-vcpkg-v2- @@ -162,7 +168,7 @@ jobs: working-directory: loop run: | cmake -B build -S . -DLOOP_INSTALL_QT_DEPENDENCIES=0 -DCMAKE_TOOLCHAIN_FILE=../vcpkg/scripts/buildsystems/vcpkg.cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_VCPKG_BUILD_TYPE=Release -DLOOP_INSTALL_TO_USR=ON -DLOOP_LOOP_DISTRIBUTION=ON -DLOOP_PLUGIN_OCR=OFF -DLOOP_BUILD_PRODUCT_QUICK_A11Y_SMOKE=ON - cmake --build build --target all release_translations -j6 + cmake --build build --target LoopEditor PdfTool ProductQuickAccessibilitySmoke release_translations -j6 cmake --install build pwsh ./scripts/verify-loop-surface.ps1 -InstallDir "$GITHUB_WORKSPACE/loop/build/install" -Profile loop-release -BuildDir build -InstallManifestPath build/install_manifest.txt @@ -196,6 +202,20 @@ jobs: - name: 'Linux Deploy Qt' working-directory: loop/build run: | + evidence_dir="$RUNNER_TEMP/loop-package-boundary-linux" + mkdir -p "$evidence_dir" + set -o pipefail + loop_quick_src="$GITHUB_WORKSPACE/loop/build/LoopEditor/Loop/Quick" + qml_dest="install/usr/lib/qml/Loop/Quick" + if [ ! -d "$loop_quick_src" ]; then + echo "::error::Built Loop.Quick QML module was not found: $loop_quick_src" + exit 1 + fi + mkdir -p "$(dirname "$qml_dest")" + cp -a "$loop_quick_src" "$qml_dest" + # Qt 6.11 ships optional SQL drivers that linuxdeployqt probes but we do + # not bundle. Remove them so deploy does not fail on missing vendor libs. + rm -rf "${QT_ROOT_DIR}/plugins/sqldrivers" cp install/usr/share/icons/hicolor/scalable/apps/io.github.mberrys.Loop-pdf.svg install/io.github.mberrys.Loop-pdf.svg bash "$GITHUB_WORKSPACE/loop/scripts/ci/download_verified.sh" \ --gh-asset "probonopd/linuxdeployqt" \ @@ -203,7 +223,10 @@ jobs: deploy.AppImage \ "$LINUXDEPLOYQT_SHA256" chmod +x deploy.AppImage - ./deploy.AppImage install/usr/share/applications/io.github.mberrys.Loop-pdf.desktop -executable-dir=install/usr/bin -extra-plugins=iconengines,imageformats,texttospeech + ./deploy.AppImage --appimage-extract-and-run \ + install/usr/share/applications/io.github.mberrys.Loop-pdf.desktop \ + -executable-dir=install/usr/bin \ + -extra-plugins=iconengines,imageformats,texttospeech 2>&1 | tee "$evidence_dir/linuxdeployqt.txt" - name: Prepare GPG home if: vars.SIGN_APPIMAGE == 'true' diff --git a/.github/workflows/WindowsInstall.yml b/.github/workflows/WindowsInstall.yml index f2576987c..8e589feea 100644 --- a/.github/workflows/WindowsInstall.yml +++ b/.github/workflows/WindowsInstall.yml @@ -185,8 +185,7 @@ jobs: cd vcpkg .\bootstrap-vcpkg.bat -disableMetrics .\vcpkg integrate install - set VCPKG_ROOT=${env:GITHUB_WORKSPACE}\vcpkg\ - set "VCPKG_BINARY_SOURCES=clear;files,${env:GITHUB_WORKSPACE}\vcpkg\archives,readwrite" + "VCPKG_BINARY_SOURCES=clear;files,$env:GITHUB_WORKSPACE\vcpkg-binary-cache,readwrite" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: 'VCPKG: Cache vcpkg dependencies' uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 @@ -194,8 +193,8 @@ jobs: path: | ./vcpkg/downloads ./vcpkg/packages - ./vcpkg/installed - ./vcpkg/archives + ./vcpkg_installed + ./vcpkg-binary-cache key: ${{ runner.os }}-vcpkg-v2-${{ hashFiles('**/vcpkg.json', '**/vcpkg-configuration.json') }} restore-keys: | ${{ runner.os }}-vcpkg-v2- @@ -252,11 +251,56 @@ jobs: shell: pwsh run: | cmake -B build -S . -DCMAKE_BUILD_TYPE=Release -DCMAKE_VCPKG_BUILD_TYPE=Release -DLOOP_INSTALL_QT_DEPENDENCIES=ON -DLOOP_INSTALL_DEPENDENCIES=ON -DCMAKE_TOOLCHAIN_FILE="${env:GITHUB_WORKSPACE}\vcpkg\scripts\buildsystems\vcpkg.cmake" -DLOOP_QT_ROOT="${env:QT_ROOT_DIR}" -DLOOP_INSTALL_MSVC_REDISTRIBUTABLE=ON -DLOOP_INSTALL_PREPARE_WIX_INSTALLER=ON -DLOOP_INSTALL_TO_USR=ON -DLOOP_LOOP_DISTRIBUTION=ON -DLOOP_PLUGIN_OCR=OFF -DLOOP_BUNDLE_OCR_SERVICE=OFF -DLOOP_BUILD_PRODUCT_QUICK_A11Y_SMOKE=ON - cmake --build build --target release_translations --config Release -j6 - cmake --build build --config Release -j6 - ctest --test-dir build -C Release --output-on-failure + cmake --build build --target LoopEditor PdfTool ProductQuickAccessibilitySmoke release_translations --config Release -j6 cmake --install build --config Release .\scripts\verify-loop-surface.ps1 -InstallDir "${env:GITHUB_WORKSPACE}\loop\build\install\usr\bin" -Profile loop-release -BuildDir .\build -InstallManifestPath .\build\install_manifest.txt + + - name: Deploy Qt runtime closure to staged install tree + working-directory: loop + shell: pwsh + run: | + $evidenceDir = Join-Path $env:RUNNER_TEMP "loop-package-boundary-windows" + New-Item -ItemType Directory -Force -Path $evidenceDir | Out-Null + $installBin = Join-Path $env:GITHUB_WORKSPACE "loop\build\install\usr\bin" + $windeployqt = Join-Path $env:QT_ROOT_DIR "bin\windeployqt.exe" + if (-not (Test-Path -LiteralPath $windeployqt)) { + throw "Qt deployment tool was not found: $windeployqt" + } + + foreach ($name in @("LoopEditor.exe", "PdfTool.exe")) { + $target = Join-Path $installBin $name + if (-not (Test-Path -LiteralPath $target)) { + throw "Expected staged executable was not found: $target" + } + $output = @(& $windeployqt --release --no-compiler-runtime --no-translations ` + --qmldir (Join-Path $env:GITHUB_WORKSPACE "loop\LoopEditor\qml") ` + --dir $installBin $target 2>&1) + $exitCode = $LASTEXITCODE + $output | Tee-Object -FilePath (Join-Path $evidenceDir "windeployqt-$name.txt") + if ($exitCode -ne 0) { + throw "windeployqt failed for $name with exit code $exitCode" + } + } + + $builtLoopQuick = Join-Path $env:GITHUB_WORKSPACE "loop\build\LoopEditor\Loop\Quick" + $qmlLoopQuick = Join-Path $installBin "qml\Loop\Quick" + if (-not (Test-Path -LiteralPath $builtLoopQuick)) { + throw "Built Loop.Quick QML module was not found: $builtLoopQuick" + } + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $qmlLoopQuick) | Out-Null + Copy-Item -Recurse -Force -LiteralPath $builtLoopQuick -Destination $qmlLoopQuick + + # The clean installed-artifact smoke intentionally removes developer + # Qt environment variables. Make the staged tree self-describing so + # Qt resolves its bundled QML imports and plugins without the runner's + # Qt installation. + @" + [Paths] + Prefix=. + Plugins=. + Qml2Imports=qml + "@ | Set-Content -LiteralPath (Join-Path $installBin "qt.conf") -Encoding ascii + Copy-Item -LiteralPath (Join-Path $installBin "qt.conf") -Destination (Join-Path $evidenceDir "qt.conf") env: VCToolsRedistDir: ${{ env.VCToolsRedistDir }} VSCMD_ARG_TGT_ARCH: ${{ env.VSCMD_ARG_TGT_ARCH }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64ddf620c..7939bb0c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,10 +4,12 @@ on: push: branches: - dev + - unstable - stable pull_request: branches: - dev + - unstable workflow_dispatch: concurrency: @@ -41,6 +43,10 @@ jobs: python3 scripts/ci/check_phase5_residue.py - name: Verify unmanaged async launch allowlist run: python3 scripts/ci/check_unmanaged_async.py + - name: Verify interaction trace corpus + run: | + python3 -m unittest scripts.ci.test_check_interaction_traces -q + python3 scripts/ci/check_interaction_traces.py --corpus-only - name: Verify semantic-trust source boundaries run: python3 scripts/ci/test_check_trust_contract_sources.py && python3 scripts/ci/check_trust_contract_sources.py - name: Verify generated dependency paths are untracked diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 017f98aea..9db8512d3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,9 +13,9 @@ name: "CodeQL Advanced" on: push: - branches: [ "dev", "stable" ] + branches: [ "dev", "unstable", "stable" ] pull_request: - branches: [ "dev", "stable" ] + branches: [ "dev", "unstable", "stable" ] schedule: - cron: '31 16 * * 4' diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 2327647a0..42ab62328 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -5,10 +5,12 @@ on: branches: - stable - dev + - unstable pull_request: branches: - stable - dev + - unstable workflow_dispatch: permissions: diff --git a/AGENTS.md b/AGENTS.md index bb7005f92..ed748f9d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,8 +5,8 @@ Repository: `studio-berry/loop`; version: `0.2.0-alpha`; language: `C++20`; mini ## Branches and safety -- Integration: `dev`; release/default: `stable`; topic branches start from `dev`. -- Protected branches: `dev`, `stable`. Do not commit, push, merge, force-push, or rewrite history without approval. +- Integration: `dev`; qualification: `unstable`; release/default: `stable`; topic branches start from `dev`. Promotion: `dev` → `unstable` → `stable`. +- Protected branches: `unstable`, `stable`. Do not commit, push, merge, force-push, or rewrite history without approval. - Keep private data, credentials, logs, scratch plans, and build artifacts outside the repository. Do not edit vendored dependencies unless explicitly scoped. ## Autonomous verification budget diff --git a/LoopEditor/CMakeLists.txt b/LoopEditor/CMakeLists.txt index ab1c3bed7..d232478e1 100644 --- a/LoopEditor/CMakeLists.txt +++ b/LoopEditor/CMakeLists.txt @@ -29,6 +29,8 @@ add_library(LoopEditorQuick STATIC editorhost.h focusrestoration.cpp focusrestoration.h + quickdocumentmodel.cpp + quickdocumentmodel.h ) qt_add_qml_module(LoopEditorQuick @@ -38,6 +40,7 @@ qt_add_qml_module(LoopEditorQuick QML_FILES qml/Main.qml qml/Workspace.qml + qml/DocumentPane.qml qml/CanvasPane.qml qml/PreflightPane.qml qml/InspectorPane.qml diff --git a/LoopEditor/editorhost.cpp b/LoopEditor/editorhost.cpp index 143d8b34b..e0b0109eb 100644 --- a/LoopEditor/editorhost.cpp +++ b/LoopEditor/editorhost.cpp @@ -121,6 +121,7 @@ EditorHost::EditorHost(QObject* parent) : connectInteraction(); connectSurfaces(); registerShellHandlers(); + registerFeatureHandlers(); m_preflightOverlayBridge.setFindingsModel(m_preflight.findingsModel()); m_preflightOverlayBridge.setOverlayBuilder(m_session->overlays()); @@ -131,6 +132,11 @@ EditorHost::EditorHost(QObject* parent) : connect(&m_preflight, &pdfinteraction::PreflightController::navigationRequested, this, &EditorHost::onPreflightNavigation); connect(&m_inspector, &pdfinteraction::InspectorModel::selectionChanged, this, &EditorHost::bumpPresentation); connect(&m_preview, &pdfinteraction::PreviewStateModel::stateChanged, this, &EditorHost::bumpPresentation); + connect(&m_documentModel, &QuickDocumentModel::searchChanged, this, [this] + { + refreshFeatureAvailability(); + bumpPresentation(); + bumpCommandEpoch(); }); } EditorHost::~EditorHost() @@ -208,6 +214,28 @@ QObject* EditorHost::preview() return &m_preview; } +void EditorHost::goToPage(int pageIndex) +{ + if (!hasDocument()) + { + return; + } + + m_session->commandBridge().goToPage(pageIndex); + bumpPresentation(); +} + +void EditorHost::acknowledgeWorkspaceRequest() +{ + if (m_workspaceRequest < 0) + { + return; + } + + m_workspaceRequest = -1; + Q_EMIT presentationChanged(); +} + QString EditorHost::preflightStateName() const { return preflightStateToString(m_preflight.state()); @@ -497,29 +525,47 @@ QString EditorHost::shortcutForCommand(const QString& commandId) const void EditorHost::connectFacade() { + connect(&m_session->context(), &pdf::PDFDocumentContext::revisionChanged, + this, + [this](const pdf::PDFRevisionIdentity&, const pdf::PDFRevisionIdentity&) + { + if (m_documentBound) + { + m_documentModel.setDocument(&m_session->context()); + m_searchRow = -1; + bumpPresentation(); + } + }); + connect(&m_session->facade(), &pdfinteraction::DocumentFacade::stateChanged, this, [this](pdfinteraction::DocumentState state) { + syncDocumentLifecycle(); if (state == pdfinteraction::DocumentState::Empty || state == pdfinteraction::DocumentState::Error) { onDocumentGone(); } bumpPresentation(); + refreshFeatureAvailability(); bumpCommandEpoch(); }); connect(&m_session->facade(), &pdfinteraction::DocumentFacade::facetsChanged, this, [this](pdfinteraction::DocumentFacets) - { bumpPresentation(); }); + { + syncDocumentLifecycle(); + bumpPresentation(); }); connect(&m_session->facade(), &pdfinteraction::DocumentFacade::documentReplaced, this, [this](quint64) { onDocumentGone(); onDocumentReady(); + refreshFeatureAvailability(); bumpPresentation(); bumpCommandEpoch(); }); connect(&m_session->facade(), &pdfinteraction::DocumentFacade::documentClosed, this, [this](quint64) { onDocumentGone(); + refreshFeatureAvailability(); bumpPresentation(); bumpCommandEpoch(); }); } @@ -562,6 +608,80 @@ void EditorHost::registerShellHandlers() m_session->catalog().setEnabled(QuitCommandId, true); } +void EditorHost::registerFeatureHandlers() +{ + auto bind = [this](const QString& id, std::function action) + { + pdfinteraction::CommandCatalog::Handler handler; + handler.invoke = [this, action = std::move(action)](pdfinteraction::CommandInvocationId invocation, + const QVariantMap&) + { + action(); + m_session->catalog().finishInvocation(invocation, pdfinteraction::CommandTerminalState::Completed); + bumpPresentation(); + }; + m_session->catalog().setHandler(id, std::move(handler)); + }; + + bind(QStringLiteral("actionPageLayoutContinuous"), [this] + { m_session->viewport().setPageLayout(pdfinteraction::PageLayout::OneColumn); }); + bind(QStringLiteral("actionPageLayoutSinglePage"), [this] + { m_session->viewport().setPageLayout(pdfinteraction::PageLayout::SinglePage); }); + bind(QStringLiteral("actionPageLayoutTwoColumns"), [this] + { m_session->viewport().setPageLayout(pdfinteraction::PageLayout::TwoColumnLeft); }); + bind(QStringLiteral("actionPageLayoutTwoPages"), [this] + { m_session->viewport().setPageLayout(pdfinteraction::PageLayout::TwoPagesLeft); }); + bind(QStringLiteral("actionFullscreenMode"), [this] + { m_fullscreenRequested = !m_fullscreenRequested; }); + bind(QStringLiteral("actionFind"), [this] + { + m_searchPanelVisible = true; + m_workspaceRequest = 0; }); + bind(QStringLiteral("actionFindNext"), [this] + { moveSearch(1); }); + bind(QStringLiteral("actionFindPrevious"), [this] + { moveSearch(-1); }); + bind(QStringLiteral("actionProperties"), [this] + { m_workspaceRequest = 2; }); + refreshFeatureAvailability(); +} + +void EditorHost::refreshFeatureAvailability() +{ + const bool ready = hasDocument(); + QHash availability; + for (const QString& id : { QStringLiteral("actionPageLayoutContinuous"), QStringLiteral("actionPageLayoutSinglePage"), + QStringLiteral("actionPageLayoutTwoColumns"), QStringLiteral("actionPageLayoutTwoPages"), + QStringLiteral("actionFind"), QStringLiteral("actionProperties") }) + { + availability.insert(id, ready); + } + const bool hasSearchResults = ready && m_documentModel.searchResultCount() > 0; + availability.insert(QStringLiteral("actionFindNext"), hasSearchResults); + availability.insert(QStringLiteral("actionFindPrevious"), hasSearchResults); + availability.insert(QStringLiteral("actionFullscreenMode"), true); + m_session->catalog().setEnabledBatch(availability); +} + +void EditorHost::moveSearch(int direction) +{ + const int count = m_documentModel.searchResults()->rowCount(); + if (count == 0) + { + return; + } + + if (m_searchRow < 0) + { + m_searchRow = direction > 0 ? 0 : count - 1; + } + else + { + m_searchRow = (m_searchRow + direction + count) % count; + } + goToPage(m_documentModel.searchPageAt(m_searchRow)); +} + void EditorHost::refreshHitTestSources() { m_findingsHitTest.setTargets(m_preflight.findingsModel()->interactionTargets()); @@ -594,6 +714,9 @@ void EditorHost::onDocumentReady() m_session->prepareDocumentView(); syncRevisionModels(); + m_documentModel.setDocument(&m_session->context()); + syncDocumentLifecycle(); + m_searchRow = -1; refreshHitTestSources(); m_documentBound = true; bindCanvas(); @@ -601,12 +724,37 @@ void EditorHost::onDocumentReady() announceDocumentState(tr("Document ready.")); } +void EditorHost::syncDocumentLifecycle() +{ + const auto& facade = m_session->facade(); + QString outputState; + switch (facade.outputState()) + { + case pdfinteraction::DocumentOutputState::None: + outputState = QStringLiteral("none"); + break; + case pdfinteraction::DocumentOutputState::Pending: + outputState = QStringLiteral("pending"); + break; + case pdfinteraction::DocumentOutputState::Saved: + outputState = QStringLiteral("saved"); + break; + } + + m_documentModel.setLifecycleState(QString::fromLatin1(pdfinteraction::getDocumentStateName(facade.state())), + facade.facets().testFlag(pdfinteraction::DocumentFacet::Dirty), + facade.facets().testFlag(pdfinteraction::DocumentFacet::Stale), + std::move(outputState), facade.typedError()); +} + void EditorHost::onDocumentGone() { unbindCanvas(); m_session->clearDocumentView(); m_preflight.findingsModel()->clear(); m_inspector.clearSelection(); + m_documentModel.clear(); + m_searchRow = -1; m_preview.clear(); m_session->hitTest()->clearSources(); m_documentBound = false; diff --git a/LoopEditor/editorhost.h b/LoopEditor/editorhost.h index 5f756804b..0ed35526b 100644 --- a/LoopEditor/editorhost.h +++ b/LoopEditor/editorhost.h @@ -40,6 +40,7 @@ #include "focusrestoration.h" #include "documentviewsession.h" +#include "quickdocumentmodel.h" #include "pdfdocumentcontext.h" #include "pdfjobscheduler.h" @@ -87,6 +88,7 @@ class EditorHost final : public QObject Q_PROPERTY(QObject* preflight READ preflight CONSTANT) Q_PROPERTY(QObject* inspector READ inspector CONSTANT) Q_PROPERTY(QObject* preview READ preview CONSTANT) + Q_PROPERTY(QObject* documentModel READ documentModel CONSTANT) Q_PROPERTY(QObject* focusRestoration READ focusRestoration CONSTANT) Q_PROPERTY(QString preflightStateName READ preflightStateName NOTIFY presentationChanged) Q_PROPERTY(QString previewSummary READ previewSummary NOTIFY presentationChanged) @@ -96,6 +98,9 @@ class EditorHost final : public QObject Q_PROPERTY(bool pageFidelityIsExact READ pageFidelityIsExact NOTIFY presentationChanged) Q_PROPERTY(QString pageFidelityReason READ pageFidelityReason NOTIFY presentationChanged) Q_PROPERTY(bool pageFidelityIsAuthoritative READ pageFidelityIsAuthoritative NOTIFY presentationChanged) + Q_PROPERTY(bool searchPanelVisible READ searchPanelVisible NOTIFY presentationChanged) + Q_PROPERTY(bool fullscreenRequested READ fullscreenRequested NOTIFY presentationChanged) + Q_PROPERTY(int workspaceRequest READ workspaceRequest NOTIFY presentationChanged) public: explicit EditorHost(QObject* parent = nullptr); @@ -120,6 +125,7 @@ class EditorHost final : public QObject QObject* preflight(); QObject* inspector(); QObject* preview(); + QObject* documentModel() { return &m_documentModel; } FocusRestoration* focusRestoration() { return &m_focusRestoration; } QString preflightStateName() const; @@ -127,6 +133,9 @@ class EditorHost final : public QObject QString inspectorTitle() const; bool preferReducedMotion() const; bool highContrast() const; + bool searchPanelVisible() const noexcept { return m_searchPanelVisible; } + bool fullscreenRequested() const noexcept { return m_fullscreenRequested; } + int workspaceRequest() const noexcept { return m_workspaceRequest; } /// Overprint render fidelity for the currently displayed page (issue #49). /// True (and pageFidelityReason empty) when the page has no overprint @@ -149,6 +158,8 @@ class EditorHost final : public QObject /// authoritative overprint-accurate one. Re-renders only that page; /// the document stays open. Q_INVOKABLE void toggleCurrentPageFidelity(); + Q_INVOKABLE void goToPage(int pageIndex); + Q_INVOKABLE void acknowledgeWorkspaceRequest(); Q_INVOKABLE QVariantList commandDescriptors() const; Q_INVOKABLE bool isCommandEnabled(const QString& commandId) const; @@ -193,12 +204,16 @@ class EditorHost final : public QObject void connectInteraction(); void connectSurfaces(); void registerShellHandlers(); + void registerFeatureHandlers(); + void refreshFeatureAvailability(); + void moveSearch(int direction); void refreshHitTestSources(); void bumpPresentation(); void bumpCommandEpoch(); void onDocumentReady(); void onDocumentGone(); + void syncDocumentLifecycle(); void bindCanvas(); void unbindCanvas(); void syncRevisionModels(); @@ -211,12 +226,17 @@ class EditorHost final : public QObject pdfinteraction::PreflightOverlayBridge m_preflightOverlayBridge; pdfinteraction::InspectorModel m_inspector; pdfinteraction::PreviewStateModel m_preview; + QuickDocumentModel m_documentModel; FocusRestoration m_focusRestoration; pdfinteraction::FindingListHitTestSource m_findingsHitTest; QPointer m_canvas; int m_commandEpoch = 0; bool m_documentBound = false; + bool m_searchPanelVisible = false; + bool m_fullscreenRequested = false; + int m_workspaceRequest = -1; + int m_searchRow = -1; }; #endif // EDITORHOST_H diff --git a/LoopEditor/qml/DocumentPane.qml b/LoopEditor/qml/DocumentPane.qml new file mode 100644 index 000000000..6b80ef757 --- /dev/null +++ b/LoopEditor/qml/DocumentPane.qml @@ -0,0 +1,169 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Loop.Quick + +Item { + id: root + + property var host: editorHost + property var documentModel: host ? host.documentModel : null + + function revealSearch() { + tabBar.currentIndex = 2 + searchField.forceActiveFocus() + } + + RowLayout { + anchors.fill: parent + spacing: 0 + + Pane { + Layout.preferredWidth: 230 + Layout.fillHeight: true + padding: 8 + + ColumnLayout { + anchors.fill: parent + spacing: 8 + + TabBar { + id: tabBar + Layout.fillWidth: true + + TabButton { + text: qsTr("Pages") + } + TabButton { + text: qsTr("Outline") + } + TabButton { + text: qsTr("Search") + } + } + + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: tabBar.currentIndex + + ListView { + id: pagesView + clip: true + focus: true + activeFocusOnTab: true + model: root.documentModel ? root.documentModel.pages : null + Accessible.name: qsTr("Page thumbnails") + + delegate: ItemDelegate { + width: pagesView.width + text: qsTr("Page %1 %2 × %3").arg(pageNumber).arg(Math.round(pageWidth)).arg(Math.round(pageHeight)) + highlighted: root.host && root.host.currentPage === index + Accessible.name: text + onClicked: if (root.host) + root.host.goToPage(index) + } + } + + TreeView { + id: outlineView + clip: true + focus: true + activeFocusOnTab: true + model: root.documentModel ? root.documentModel.outline : null + Accessible.name: qsTr("Document outline") + + delegate: ItemDelegate { + width: outlineView.width + text: model.display !== undefined ? model.display : title + Accessible.name: text + onClicked: if (root.host && model.index !== undefined) root.host.goToOutlineIndex(model.index) + } + + Label { + anchors.centerIn: parent + visible: outlineView.count === 0 + text: qsTr("No outline") + } + } + + ColumnLayout { + spacing: 8 + + RowLayout { + Layout.fillWidth: true + TextField { + id: searchField + Layout.fillWidth: true + placeholderText: qsTr("Find in document") + focus: true + Accessible.name: qsTr("Search text") + onAccepted: if (root.documentModel) + root.documentModel.search(text) + } + Button { + text: qsTr("Find") + enabled: searchField.text.length > 0 && !!root.documentModel + onClicked: root.documentModel.search(searchField.text) + } + } + + RowLayout { + Layout.fillWidth: true + Button { + text: qsTr("Previous") + enabled: root.host && root.host.commandEpoch >= 0 && root.host.isCommandEnabled("actionFindPrevious") + onClicked: if (root.host) + root.host.invokeCommand("actionFindPrevious") + } + Button { + text: qsTr("Next") + enabled: root.host && root.host.commandEpoch >= 0 && root.host.isCommandEnabled("actionFindNext") + onClicked: if (root.host) + root.host.invokeCommand("actionFindNext") + } + Label { + Layout.fillWidth: true + text: resultsView.count > 0 ? qsTr("%1 result(s)").arg(resultsView.count) : qsTr("No results") + } + } + + ListView { + id: resultsView + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: root.documentModel ? root.documentModel.searchResults : null + Accessible.name: qsTr("Search results") + + delegate: ItemDelegate { + width: resultsView.width + text: qsTr("Page %1: %2").arg(page + 1).arg(context) + Accessible.name: text + onClicked: if (root.host) + root.host.goToPage(page) + } + } + } + } + } + } + + CanvasPane { + id: canvasPane + Layout.fillWidth: true + Layout.fillHeight: true + host: root.host + Accessible.name: qsTr("Document canvas pane") + } + } + + Connections { + target: root.host + function onPresentationChanged() { + if (root.host && root.host.searchPanelVisible) + root.revealSearch() + } + } +} diff --git a/LoopEditor/qml/InspectorPane.qml b/LoopEditor/qml/InspectorPane.qml index 40aff5478..356cd19c0 100644 --- a/LoopEditor/qml/InspectorPane.qml +++ b/LoopEditor/qml/InspectorPane.qml @@ -7,6 +7,7 @@ Pane { property var host: editorHost property var inspectorModel: host ? host.inspector : null + property var documentModel: host ? host.documentModel : null padding: 8 @@ -62,5 +63,44 @@ Pane { text: host ? qsTr("Preview: %1").arg(host.previewSummary) : "" Accessible.name: qsTr("Production preview status") } + + GroupBox { + Layout.fillWidth: true + title: qsTr("Document properties") + visible: host && host.hasDocument + + ColumnLayout { + anchors.fill: parent + spacing: 4 + + Label { + text: qsTr("Title: %1").arg(root.documentModel ? root.documentModel.title : "") + } + Label { + text: qsTr("Author: %1").arg(root.documentModel ? root.documentModel.author : "") + } + Label { + text: qsTr("PDF version: %1").arg(root.documentModel ? root.documentModel.version : "") + } + Label { + text: qsTr("Attachments: %1").arg(root.documentModel && root.documentModel.hasAttachments ? qsTr("present") : qsTr("none")) + } + Label { + text: qsTr("Optional content: %1").arg(root.documentModel && root.documentModel.hasOptionalContent ? qsTr("present") : qsTr("none")) + } + Label { + text: qsTr("Lifecycle: %1").arg(root.documentModel ? root.documentModel.lifecycleState : "") + } + Label { + text: qsTr("Document state: %1").arg(root.documentModel && root.documentModel.modified ? qsTr("modified") : qsTr("unchanged")) + } + Label { + text: qsTr("Security: %1").arg(root.documentModel && root.documentModel.encrypted ? qsTr("encrypted") : qsTr("not encrypted")) + } + Label { + text: qsTr("Permissions: %1").arg(root.documentModel && root.documentModel.canPrint ? qsTr("printing allowed") : qsTr("printing restricted")) + } + } + } } } diff --git a/LoopEditor/qml/Main.qml b/LoopEditor/qml/Main.qml index 7432145a1..f73397e69 100644 --- a/LoopEditor/qml/Main.qml +++ b/LoopEditor/qml/Main.qml @@ -66,6 +66,13 @@ ApplicationWindow { } else { window.title = qsTr("Loop") } + if (host) { + if (host.fullscreenRequested) { + window.visibility = Window.FullScreen + } else if (window.visibility === Window.FullScreen) { + window.visibility = Window.Windowed + } + } } } @@ -147,6 +154,13 @@ ApplicationWindow { Menu { title: qsTr("&View") + Action { + text: qsTr("&Find…") + enabled: commandEnabled("actionFind") + shortcut: shortcutSequence(commandMap["actionFind"]) + onTriggered: invoke("actionFind") + } + MenuSeparator {} Action { text: qsTr("Zoom &In") enabled: commandEnabled("actionZoom_In") @@ -190,6 +204,42 @@ ApplicationWindow { shortcut: shortcutSequence(commandMap["actionRotateRight"]) onTriggered: invoke("actionRotateRight") } + MenuSeparator {} + Action { + text: qsTr("Continuous Layout") + enabled: commandEnabled("actionPageLayoutContinuous") + onTriggered: invoke("actionPageLayoutContinuous") + } + Action { + text: qsTr("Single Page Layout") + enabled: commandEnabled("actionPageLayoutSinglePage") + onTriggered: invoke("actionPageLayoutSinglePage") + } + Action { + text: qsTr("Two-Column Layout") + enabled: commandEnabled("actionPageLayoutTwoColumns") + onTriggered: invoke("actionPageLayoutTwoColumns") + } + Action { + text: qsTr("Two-Page Layout") + enabled: commandEnabled("actionPageLayoutTwoPages") + onTriggered: invoke("actionPageLayoutTwoPages") + } + Action { + text: qsTr("Fullscreen") + enabled: commandEnabled("actionFullscreenMode") + shortcut: shortcutSequence(commandMap["actionFullscreenMode"]) + onTriggered: invoke("actionFullscreenMode") + } + } + + Menu { + title: qsTr("&Document") + Action { + text: qsTr("&Properties") + enabled: commandEnabled("actionProperties") + onTriggered: invoke("actionProperties") + } } } diff --git a/LoopEditor/qml/Workspace.qml b/LoopEditor/qml/Workspace.qml index 940cfbc30..9e300a655 100644 --- a/LoopEditor/qml/Workspace.qml +++ b/LoopEditor/qml/Workspace.qml @@ -71,10 +71,9 @@ Item { Layout.fillHeight: true currentIndex: 0 - CanvasPane { - id: canvasPane + DocumentPane { + id: documentPane host: root.host - Accessible.name: qsTr("Document canvas pane") } PreflightPane { @@ -87,5 +86,15 @@ Item { } } - KeyNavigation.tab: canvasPane.canvasItem + KeyNavigation.tab: documentPane + + Connections { + target: root.host + function onPresentationChanged() { + if (root.host && root.host.workspaceRequest >= 0) { + workspaceStack.currentIndex = root.host.workspaceRequest + root.host.acknowledgeWorkspaceRequest() + } + } + } } diff --git a/LoopEditor/quickdocumentmodel.cpp b/LoopEditor/quickdocumentmodel.cpp new file mode 100644 index 000000000..08d15541d --- /dev/null +++ b/LoopEditor/quickdocumentmodel.cpp @@ -0,0 +1,369 @@ +// MIT License +#include "quickdocumentmodel.h" + +#include "pdfcatalog.h" +#include "pdfdocument.h" +#include "pdfdocumentcontext.h" +#include "pdfdocumentsearch.h" +#include "pdfdocumentsession.h" +#include "pdfform.h" +#include "pdfoutline.h" +#include "pdfpage.h" +#include "pdfutils.h" + +#include + +QuickPageModel::QuickPageModel(QObject* parent) : + QAbstractListModel(parent) +{ +} + +int QuickPageModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) + return 0; + return m_pages.size(); +} + +QVariant QuickPageModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_pages.size()) + return {}; + + const auto& page = m_pages.at(index.row()); + switch (role) + { + case Qt::DisplayRole: + case PageNumberRole: + return index.row() + 1; + case WidthRole: + return page.width; + case HeightRole: + return page.height; + case RotationRole: + return page.rotation; + case LabelRole: + return QString::number(index.row() + 1); + } + return {}; +} + +QHash QuickPageModel::roleNames() const +{ + return { { PageNumberRole, "pageNumber" }, { WidthRole, "pageWidth" }, { HeightRole, "pageHeight" }, { RotationRole, "pageRotation" }, { LabelRole, "label" } }; +} + +void QuickPageModel::replace(const pdf::PDFDocument* document) +{ + beginResetModel(); + m_pages.clear(); + if (document) + { + const pdf::PDFCatalog* catalog = document->getCatalog(); + m_pages.reserve(static_cast(catalog->getPageCount())); + for (size_t i = 0; i < catalog->getPageCount(); ++i) + { + const pdf::PDFPage* page = catalog->getPage(i); + m_pages.append(Page{ page->getCropBox().width(), page->getCropBox().height(), + static_cast(page->getPageRotation()), static_cast(i) }); + } + } + endResetModel(); +} + +void QuickPageModel::clear() +{ + replace(nullptr); +} + +QuickOutlineModel::QuickOutlineModel(QObject* parent) : + QAbstractItemModel(parent), + m_root(std::make_unique()) +{ +} +QuickOutlineModel::~QuickOutlineModel() = default; + +QuickOutlineModel::Node* QuickOutlineModel::nodeForIndex(const QModelIndex& index) const +{ + return index.isValid() ? static_cast(index.internalPointer()) : m_root.get(); +} + +QModelIndex QuickOutlineModel::indexForNode(Node* node) const +{ + if (!node || node == m_root.get() || !node->parent) + return {}; + for (int row = 0; row < static_cast(node->parent->children.size()); ++row) + { + if (node->parent->children.at(row).get() == node) + return createIndex(row, 0, node); + } + return {}; +} + +QModelIndex QuickOutlineModel::index(int row, int column, const QModelIndex& parent) const +{ + if (column != 0 || row < 0) + return {}; + Node* parentNode = nodeForIndex(parent); + if (!parentNode || row >= static_cast(parentNode->children.size())) + return {}; + return createIndex(row, column, parentNode->children.at(static_cast(row)).get()); +} + +QModelIndex QuickOutlineModel::parent(const QModelIndex& child) const +{ + if (!child.isValid()) + return {}; + return indexForNode(static_cast(child.internalPointer())->parent); +} + +int QuickOutlineModel::rowCount(const QModelIndex& parent) const +{ + return static_cast(nodeForIndex(parent)->children.size()); +} + +int QuickOutlineModel::columnCount(const QModelIndex&) const +{ + return 1; +} + +QVariant QuickOutlineModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return {}; + const Node* node = static_cast(index.internalPointer()); + if (!node->item) + return {}; + if (role == Qt::DisplayRole || role == TitleRole) + return node->item->getTitle(); + if (role == HasChildrenRole) + return !node->children.empty(); + return {}; +} + +QHash QuickOutlineModel::roleNames() const +{ + return { { TitleRole, "title" }, { HasChildrenRole, "hasChildren" } }; +} + +void QuickOutlineModel::build(Node* parent, const pdf::PDFOutlineItem* item) +{ + if (!item) + return; + for (size_t i = 0; i < item->getChildCount(); ++i) + { + auto child = std::make_unique(); + child->item = item->getChild(i); + child->parent = parent; + Node* childNode = child.get(); + parent->children.push_back(std::move(child)); + build(childNode, childNode->item); + } +} + +void QuickOutlineModel::replace(const pdf::PDFDocument* document) +{ + beginResetModel(); + m_root = std::make_unique(); + if (document) + build(m_root.get(), document->getCatalog()->getOutlineRootPtr().data()); + endResetModel(); +} + +void QuickOutlineModel::clear() +{ + replace(nullptr); +} + +QuickSearchResultModel::QuickSearchResultModel(QObject* parent) : + QAbstractListModel(parent) +{ +} + +int QuickSearchResultModel::rowCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : m_results.size(); +} + +QVariant QuickSearchResultModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_results.size()) + return {}; + const Result& result = m_results.at(index.row()); + if (role == Qt::DisplayRole || role == MatchedRole) + return result.matched; + if (role == PageRole) + return result.page; + if (role == ContextRole) + return result.context; + return {}; +} + +QHash QuickSearchResultModel::roleNames() const +{ + return { { PageRole, "page" }, { MatchedRole, "matched" }, { ContextRole, "context" } }; +} + +void QuickSearchResultModel::replace(QList results, QString query, QString revision) +{ + beginResetModel(); + m_results = std::move(results); + m_query = std::move(query); + m_revision = std::move(revision); + endResetModel(); +} + +void QuickSearchResultModel::clear() +{ + replace({}, {}, {}); +} + +QuickDocumentModel::QuickDocumentModel(QObject* parent) : + QObject(parent), + m_pages(this), + m_outline(this), + m_searchResults(this) +{ +} + +void QuickDocumentModel::setDocument(pdf::PDFDocumentContext* context) +{ + m_context = context; + m_session = context ? context->getSession() : nullptr; + const pdf::PDFDocument* document = context ? context->getDocument() : nullptr; + + if (!document || !m_session) + { + clear(); + return; + } + + m_pages.replace(document); + m_outline.replace(document); + const pdf::PDFDocumentInfo* info = document->getInfo(); + const pdf::PDFCatalog* catalog = document->getCatalog(); + m_title = info->title; + m_author = info->author; + m_subject = info->subject; + m_creator = info->creator; + m_producer = info->producer; + m_version = QString::fromLatin1(document->getVersion()); + m_revision = context->getRevision().toString(); + m_hasOutline = catalog->getOutlineRootPtr() && catalog->getOutlineRootPtr()->getChildCount() > 0; + m_hasAttachments = !catalog->getEmbeddedFiles().empty(); + m_hasOptionalContent = !catalog->getOptionalContentProperties()->getAllOptionalContentGroups().empty(); + m_hasForm = !catalog->getFormObject().isNull(); + m_hasLogicalStructure = catalog->isLogicalStructureMarked(); + + const pdf::PDFSecurityHandler* security = document->getStorage().getSecurityHandler(); + m_encrypted = security && security->getMode() != pdf::EncryptionMode::None; + m_canPrint = security && (security->isAllowed(pdf::PDFSecurityHandler::Permission::PrintLowResolution) || + security->isAllowed(pdf::PDFSecurityHandler::Permission::PrintHighResolution)); + m_canHighResolutionPrint = security && security->isAllowed(pdf::PDFSecurityHandler::Permission::PrintHighResolution); + m_canCopy = security && security->isAllowed(pdf::PDFSecurityHandler::Permission::CopyContent); + m_canModify = security && security->isAllowed(pdf::PDFSecurityHandler::Permission::Modify); + m_canComment = security && security->isAllowed(pdf::PDFSecurityHandler::Permission::ModifyInteractiveItems); + m_canFillForms = security && security->isAllowed(pdf::PDFSecurityHandler::Permission::ModifyFormFields); + m_canAssemble = security && security->isAllowed(pdf::PDFSecurityHandler::Permission::Assemble); + m_canAccessibility = security && security->isAllowed(pdf::PDFSecurityHandler::Permission::Accessibility); + m_searchResults.clear(); + Q_EMIT changed(); + Q_EMIT searchChanged(); +} + +void QuickDocumentModel::setLifecycleState(QString state, + bool modified, + bool stale, + QString outputState, + QString typedError) +{ + const bool stateChanged = m_lifecycleState != state || m_modified != modified || m_stale != stale || + m_outputState != outputState || m_typedError != typedError || + m_outputPending != (outputState == QStringLiteral("pending")) || + m_outputSaved != (outputState == QStringLiteral("saved")); + if (!stateChanged) + { + return; + } + + m_lifecycleState = std::move(state); + m_modified = modified; + m_stale = stale; + m_outputState = std::move(outputState); + m_typedError = std::move(typedError); + m_outputPending = m_outputState == QStringLiteral("pending"); + m_outputSaved = m_outputState == QStringLiteral("saved"); + Q_EMIT changed(); +} + +void QuickDocumentModel::clear() +{ + m_pages.clear(); + m_outline.clear(); + m_searchResults.clear(); + m_context = nullptr; + m_session = nullptr; + m_title.clear(); + m_author.clear(); + m_subject.clear(); + m_creator.clear(); + m_producer.clear(); + m_version.clear(); + m_revision.clear(); + m_hasOutline = false; + m_hasAttachments = false; + m_hasOptionalContent = false; + m_hasForm = false; + m_hasLogicalStructure = false; + m_encrypted = false; + m_canPrint = false; + m_canHighResolutionPrint = false; + m_canCopy = false; + m_canModify = false; + m_canComment = false; + m_canFillForms = false; + m_canAssemble = false; + m_canAccessibility = false; + m_modified = false; + m_stale = false; + m_outputPending = false; + m_outputSaved = false; + m_lifecycleState.clear(); + m_outputState.clear(); + m_typedError.clear(); + Q_EMIT changed(); + Q_EMIT searchChanged(); +} + +bool QuickDocumentModel::search(const QString& query) +{ + if (!m_context || !m_session || query.trimmed().isEmpty()) + { + clearSearch(); + return false; + } + + const pdf::PDFDocumentSearchResult searchResult = pdf::searchDocumentText(m_context, query); + if (!searchResult.admitted) + return false; + + QList results; + results.reserve(searchResult.matches.size()); + for (const pdf::PDFDocumentSearchMatch& match : searchResult.matches) + results.append({ static_cast(match.pageIndex), match.matched, match.context }); + m_searchResults.replace(std::move(results), query, searchResult.revision.toString()); + Q_EMIT searchChanged(); + return true; +} + +void QuickDocumentModel::clearSearch() +{ + m_searchResults.clear(); + Q_EMIT searchChanged(); +} + +int QuickDocumentModel::searchPageAt(int row) const +{ + const QModelIndex index = m_searchResults.index(row, 0); + return index.isValid() ? m_searchResults.data(index, QuickSearchResultModel::PageRole).toInt() : -1; +} diff --git a/LoopEditor/quickdocumentmodel.h b/LoopEditor/quickdocumentmodel.h new file mode 100644 index 000000000..b19e99624 --- /dev/null +++ b/LoopEditor/quickdocumentmodel.h @@ -0,0 +1,262 @@ +// MIT License +#ifndef QUICKDOCUMENTMODEL_H +#define QUICKDOCUMENTMODEL_H + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace pdf +{ +class PDFDocumentContext; +class PDFDocumentSession; +class PDFOutlineItem; +class PDFDocument; +} + +class QuickPageModel final : public QAbstractListModel +{ + Q_OBJECT + +public: + enum Role + { + PageNumberRole = Qt::UserRole + 1, + WidthRole, + HeightRole, + RotationRole, + LabelRole, + }; + + explicit QuickPageModel(QObject* parent = nullptr); + + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + void replace(const pdf::PDFDocument* document); + void clear(); + +private: + struct Page + { + qreal width = 0.0; + qreal height = 0.0; + int rotation = 0; + int index = -1; + }; + + QList m_pages; +}; + +class QuickOutlineModel final : public QAbstractItemModel +{ + Q_OBJECT + +public: + enum Role + { + TitleRole = Qt::UserRole + 1, + HasChildrenRole, + }; + + explicit QuickOutlineModel(QObject* parent = nullptr); + ~QuickOutlineModel() override; + + QModelIndex index(int row, int column, const QModelIndex& parent = {}) const override; + QModelIndex parent(const QModelIndex& child) const override; + int rowCount(const QModelIndex& parent = {}) const override; + int columnCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + void replace(const pdf::PDFDocument* document); + void clear(); + +private: + struct Node + { + const pdf::PDFOutlineItem* item = nullptr; + Node* parent = nullptr; + std::vector> children; + }; + + void build(Node* parent, const pdf::PDFOutlineItem* item); + Node* nodeForIndex(const QModelIndex& index) const; + QModelIndex indexForNode(Node* node) const; + + std::unique_ptr m_root; +}; + +class QuickSearchResultModel final : public QAbstractListModel +{ + Q_OBJECT + +public: + enum Role + { + PageRole = Qt::UserRole + 1, + MatchedRole, + ContextRole, + }; + + struct Result + { + int page = -1; + QString matched; + QString context; + }; + + explicit QuickSearchResultModel(QObject* parent = nullptr); + + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + void replace(QList results, QString query, QString revision); + void clear(); + QString query() const noexcept { return m_query; } + QString revision() const noexcept { return m_revision; } + +private: + QList m_results; + QString m_query; + QString m_revision; +}; + +class QuickDocumentModel final : public QObject +{ + Q_OBJECT + Q_PROPERTY(QAbstractItemModel* pages READ pages CONSTANT) + Q_PROPERTY(QAbstractItemModel* outline READ outline CONSTANT) + Q_PROPERTY(QAbstractItemModel* searchResults READ searchResults CONSTANT) + Q_PROPERTY(QString title READ title NOTIFY changed) + Q_PROPERTY(QString author READ author NOTIFY changed) + Q_PROPERTY(QString subject READ subject NOTIFY changed) + Q_PROPERTY(QString creator READ creator NOTIFY changed) + Q_PROPERTY(QString producer READ producer NOTIFY changed) + Q_PROPERTY(QString version READ version NOTIFY changed) + Q_PROPERTY(QString revision READ revision NOTIFY changed) + Q_PROPERTY(bool hasOutline READ hasOutline NOTIFY changed) + Q_PROPERTY(bool hasAttachments READ hasAttachments NOTIFY changed) + Q_PROPERTY(bool hasOptionalContent READ hasOptionalContent NOTIFY changed) + Q_PROPERTY(bool hasForm READ hasForm NOTIFY changed) + Q_PROPERTY(bool hasLogicalStructure READ hasLogicalStructure NOTIFY changed) + Q_PROPERTY(bool encrypted READ encrypted NOTIFY changed) + Q_PROPERTY(bool canPrint READ canPrint NOTIFY changed) + Q_PROPERTY(bool canHighResolutionPrint READ canHighResolutionPrint NOTIFY changed) + Q_PROPERTY(bool canCopy READ canCopy NOTIFY changed) + Q_PROPERTY(bool canModify READ canModify NOTIFY changed) + Q_PROPERTY(bool canComment READ canComment NOTIFY changed) + Q_PROPERTY(bool canFillForms READ canFillForms NOTIFY changed) + Q_PROPERTY(bool canAssemble READ canAssemble NOTIFY changed) + Q_PROPERTY(bool canAccessibility READ canAccessibility NOTIFY changed) + Q_PROPERTY(bool modified READ modified NOTIFY changed) + Q_PROPERTY(bool stale READ stale NOTIFY changed) + Q_PROPERTY(bool outputPending READ outputPending NOTIFY changed) + Q_PROPERTY(bool outputSaved READ outputSaved NOTIFY changed) + Q_PROPERTY(QString lifecycleState READ lifecycleState NOTIFY changed) + Q_PROPERTY(QString outputState READ outputState NOTIFY changed) + Q_PROPERTY(QString typedError READ typedError NOTIFY changed) + Q_PROPERTY(int searchResultCount READ searchResultCount NOTIFY searchChanged) + +public: + explicit QuickDocumentModel(QObject* parent = nullptr); + + QAbstractItemModel* pages() noexcept { return &m_pages; } + QAbstractItemModel* outline() noexcept { return &m_outline; } + QAbstractItemModel* searchResults() noexcept { return &m_searchResults; } + + QString title() const { return m_title; } + QString author() const { return m_author; } + QString subject() const { return m_subject; } + QString creator() const { return m_creator; } + QString producer() const { return m_producer; } + QString version() const { return m_version; } + QString revision() const { return m_revision; } + bool hasOutline() const noexcept { return m_hasOutline; } + bool hasAttachments() const noexcept { return m_hasAttachments; } + bool hasOptionalContent() const noexcept { return m_hasOptionalContent; } + bool hasForm() const noexcept { return m_hasForm; } + bool hasLogicalStructure() const noexcept { return m_hasLogicalStructure; } + bool encrypted() const noexcept { return m_encrypted; } + bool canPrint() const noexcept { return m_canPrint; } + bool canHighResolutionPrint() const noexcept { return m_canHighResolutionPrint; } + bool canCopy() const noexcept { return m_canCopy; } + bool canModify() const noexcept { return m_canModify; } + bool canComment() const noexcept { return m_canComment; } + bool canFillForms() const noexcept { return m_canFillForms; } + bool canAssemble() const noexcept { return m_canAssemble; } + bool canAccessibility() const noexcept { return m_canAccessibility; } + bool modified() const noexcept { return m_modified; } + bool stale() const noexcept { return m_stale; } + bool outputPending() const noexcept { return m_outputPending; } + bool outputSaved() const noexcept { return m_outputSaved; } + QString lifecycleState() const { return m_lifecycleState; } + QString outputState() const { return m_outputState; } + QString typedError() const { return m_typedError; } + int searchResultCount() const noexcept { return m_searchResults.rowCount(); } + + void setDocument(pdf::PDFDocumentContext* context); + void setLifecycleState(QString state, + bool modified, + bool stale, + QString outputState, + QString typedError); + void clear(); + + /// Performs a Core text search against a captured document revision. The + /// result is admitted only if the context still owns that revision. + Q_INVOKABLE bool search(const QString& query); + Q_INVOKABLE void clearSearch(); + Q_INVOKABLE int searchPageAt(int row) const; + +signals: + void changed(); + void searchChanged(); + +private: + QuickPageModel m_pages; + QuickOutlineModel m_outline; + QuickSearchResultModel m_searchResults; + pdf::PDFDocumentContext* m_context = nullptr; + pdf::PDFDocumentSession* m_session = nullptr; + QString m_title; + QString m_author; + QString m_subject; + QString m_creator; + QString m_producer; + QString m_version; + QString m_revision; + bool m_hasOutline = false; + bool m_hasAttachments = false; + bool m_hasOptionalContent = false; + bool m_hasForm = false; + bool m_hasLogicalStructure = false; + bool m_encrypted = false; + bool m_canPrint = false; + bool m_canHighResolutionPrint = false; + bool m_canCopy = false; + bool m_canModify = false; + bool m_canComment = false; + bool m_canFillForms = false; + bool m_canAssemble = false; + bool m_canAccessibility = false; + bool m_modified = false; + bool m_stale = false; + bool m_outputPending = false; + bool m_outputSaved = false; + QString m_lifecycleState; + QString m_outputState; + QString m_typedError; +}; + +#endif diff --git a/LoopLibCore/CMakeLists.txt b/LoopLibCore/CMakeLists.txt index de170f25a..5fe32a542 100644 --- a/LoopLibCore/CMakeLists.txt +++ b/LoopLibCore/CMakeLists.txt @@ -89,6 +89,8 @@ add_library(LoopLibCore SHARED sources/pdfworkloadenvelope.h sources/pdfdocumentcontext.cpp sources/pdfdocumentcontext.h + sources/pdfdocumentsearch.cpp + sources/pdfdocumentsearch.h sources/pdfprocessingbudget.cpp sources/pdfprocessingbudget.h sources/pdfjobscheduler.cpp diff --git a/LoopLibCore/sources/pdfdocumentsearch.cpp b/LoopLibCore/sources/pdfdocumentsearch.cpp new file mode 100644 index 000000000..88a1813d7 --- /dev/null +++ b/LoopLibCore/sources/pdfdocumentsearch.cpp @@ -0,0 +1,55 @@ +// MIT License +#include "pdfdocumentsearch.h" + +#include "pdfcatalog.h" +#include "pdfdocumentsession.h" +#include "pdfmeshqualitysettings.h" +#include "pdfpage.h" +#include "pdftextlayout.h" +#include "pdftextlayoutgenerator.h" + +namespace pdf +{ + +PDFDocumentSearchResult searchDocumentText(PDFDocumentContext* context, + const QString& query, + Qt::CaseSensitivity sensitivity) +{ + PDFDocumentSearchResult result; + if (!context || query.trimmed().isEmpty()) + return result; + + PDFDocumentSession* session = context->getSession(); + const PDFDocument* document = context->getDocument(); + if (!session || !document) + return result; + + result.revision = context->getRevision(); + const PDFMeshQualitySettings meshQuality; + const PDFRenderer::Features features = PDFRenderer::IgnoreOptionalContent; + const PDFCatalog* catalog = document->getCatalog(); + for (size_t pageIndex = 0; pageIndex < catalog->getPageCount(); ++pageIndex) + { + const PDFPage* page = catalog->getPage(pageIndex); + PDFTextLayoutGenerator generator(features, page, document, + session->getFontCache(), session->getCMS(), + session->getOptionalContentActivity(), QTransform(), meshQuality); + generator.processContents(); + const PDFTextFlows flows = PDFTextFlow::createTextFlows( + generator.createTextLayout(), + PDFTextFlow::FlowFlags(PDFTextFlow::RemoveSoftHyphen) | PDFTextFlow::AddLineBreaks, + static_cast(pageIndex)); + for (const PDFTextFlow& flow : flows) + { + for (const PDFFindResult& match : flow.find(query, sensitivity)) + result.matches.push_back({ static_cast(pageIndex), match.matched, match.context }); + } + } + + result.admitted = context->isCurrent(result.revision); + if (!result.admitted) + result.matches.clear(); + return result; +} + +} // namespace pdf diff --git a/LoopLibCore/sources/pdfdocumentsearch.h b/LoopLibCore/sources/pdfdocumentsearch.h new file mode 100644 index 000000000..7364c0a91 --- /dev/null +++ b/LoopLibCore/sources/pdfdocumentsearch.h @@ -0,0 +1,38 @@ +// MIT License +#ifndef PDFDOCUMENTSEARCH_H +#define PDFDOCUMENTSEARCH_H + +#include "pdfdocumentcontext.h" +#include "pdfglobal.h" + +#include +#include + +namespace pdf +{ + +struct LOOPLIBCORESHARED_EXPORT PDFDocumentSearchMatch +{ + PDFInteger pageIndex = -1; + QString matched; + QString context; +}; + +struct LOOPLIBCORESHARED_EXPORT PDFDocumentSearchResult +{ + QVector matches; + PDFRevisionIdentity revision; + bool admitted = false; +}; + +/// Extracts and searches the text flows for every page in the context's +/// current document. Results are admitted only while the captured revision is +/// still current, so presentation layers do not need to implement parsing or +/// revision-fencing policy themselves. +LOOPLIBCORESHARED_EXPORT PDFDocumentSearchResult searchDocumentText(PDFDocumentContext* context, + const QString& query, + Qt::CaseSensitivity sensitivity = Qt::CaseInsensitive); + +} // namespace pdf + +#endif // PDFDOCUMENTSEARCH_H diff --git a/LoopLibCore/sources/pdfworkloadenvelope.cpp b/LoopLibCore/sources/pdfworkloadenvelope.cpp index 5fbc60d98..c4a0d0e88 100644 --- a/LoopLibCore/sources/pdfworkloadenvelope.cpp +++ b/LoopLibCore/sources/pdfworkloadenvelope.cpp @@ -201,6 +201,18 @@ qint64 PDFWorkloadEnvelope::currentRssHighWaterBytes() return -1; } +qint64 PDFWorkloadEnvelope::currentProcessCommitHighWaterBytes() +{ +#ifdef Q_OS_WIN + PROCESS_MEMORY_COUNTERS counters{}; + if (GetProcessMemoryInfo(GetCurrentProcess(), &counters, sizeof(counters))) + { + return static_cast(counters.PeakPagefileUsage); + } +#endif + return -1; +} + void PDFWorkloadEnvelope::recordResources(const PDFResourceBudget& budget) { resources = budget.toJson(); @@ -225,6 +237,7 @@ QJsonObject PDFWorkloadEnvelope::toJson() const object.insert(QStringLiteral("page_count"), pageCount); object.insert(QStringLiteral("open_to_first_view_ms"), openToFirstViewMs); object.insert(QStringLiteral("rss_high_water_bytes"), rssHighWaterBytes); + object.insert(QStringLiteral("process_commit_high_water_bytes"), processCommitHighWaterBytes); object.insert(QStringLiteral("cache_high_water_bytes"), cacheHighWaterBytes); object.insert(QStringLiteral("preflight_high_water_bytes"), preflightHighWaterBytes); object.insert(QStringLiteral("pages_materialized"), pagesMaterialized); diff --git a/LoopLibCore/sources/pdfworkloadenvelope.h b/LoopLibCore/sources/pdfworkloadenvelope.h index 424b9cf99..25e584ea5 100644 --- a/LoopLibCore/sources/pdfworkloadenvelope.h +++ b/LoopLibCore/sources/pdfworkloadenvelope.h @@ -66,6 +66,9 @@ struct LOOPLIBCORESHARED_EXPORT PDFWorkloadEnvelope // -1 means that the platform could not provide this measurement. It is // intentionally distinct from zero so unavailable evidence cannot pass. qint64 rssHighWaterBytes = -1; + // Windows exposes peak commit charge separately from peak working set. + // Linux leaves this unavailable rather than substituting virtual size. + qint64 processCommitHighWaterBytes = -1; qint64 cacheHighWaterBytes = -1; qint64 preflightHighWaterBytes = -1; qint64 pagesMaterialized = -1; @@ -78,6 +81,7 @@ struct LOOPLIBCORESHARED_EXPORT PDFWorkloadEnvelope QJsonObject resources; static qint64 currentRssHighWaterBytes(); + static qint64 currentProcessCommitHighWaterBytes(); void recordResources(const PDFResourceBudget& budget); QJsonObject toJson() const; }; diff --git a/PdfTool/pdftoolrender.cpp b/PdfTool/pdftoolrender.cpp index 230340023..a1910fc98 100644 --- a/PdfTool/pdftoolrender.cpp +++ b/PdfTool/pdftoolrender.cpp @@ -214,6 +214,7 @@ void PDFToolBenchmark::finish(const PDFToolOptions& options) : QStringLiteral("incomplete"); envelope.pageCount = static_cast(m_pageInfo.size()); envelope.rssHighWaterBytes = pdf::PDFWorkloadEnvelope::currentRssHighWaterBytes(); + envelope.processCommitHighWaterBytes = pdf::PDFWorkloadEnvelope::currentProcessCommitHighWaterBytes(); envelope.elapsedMs = m_wallTime; envelope.cancellationLatencyMs = cancelled ? cancellationLatencyMs() : -1; envelope.incompleteReason = cancelled diff --git a/UnitTests/CMakeLists.txt b/UnitTests/CMakeLists.txt index 02f59cc82..a98bd9fb7 100644 --- a/UnitTests/CMakeLists.txt +++ b/UnitTests/CMakeLists.txt @@ -78,6 +78,21 @@ set_target_properties(UnitTestsJobScheduler PROPERTIES add_test(UnitTestsJobScheduler "${CMAKE_BINARY_DIR}/${LOOP_INSTALL_BIN_DIR}/UnitTestsJobScheduler") +add_executable(UnitTestsRevisionStress + tst_revisionstresstest.cpp +) + +target_link_libraries(UnitTestsRevisionStress PRIVATE LoopLibCore Qt6::Core Qt6::Gui Qt6::Test) + +set_target_properties(UnitTestsRevisionStress PROPERTIES + WIN32_EXECUTABLE OFF + MACOSX_BUNDLE OFF + LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${LOOP_INSTALL_LIB_DIR} + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${LOOP_INSTALL_BIN_DIR} +) + +add_test(UnitTestsRevisionStress "${CMAKE_BINARY_DIR}/${LOOP_INSTALL_BIN_DIR}/UnitTestsRevisionStress") + include(${CMAKE_CURRENT_SOURCE_DIR}/phase4-tests.cmake) @@ -822,6 +837,26 @@ set_target_properties(UnitTestsBudgetExhaustion PROPERTIES ) add_test(UnitTestsBudgetExhaustion "${CMAKE_BINARY_DIR}/${LOOP_INSTALL_BIN_DIR}/UnitTestsBudgetExhaustion") +# gh-243: synthetic adversarial PDF corpus, one fixture per PDFProcessingBudget +# dimension, read through PDFDocumentReader/PreflightEngine like a real upload. +# Fixtures and manifest.json are generated (not hand-written) by +# scripts/resource_envelope/budget_exhaustion_corpus.py; see +# UnitTests/testdata/budget_exhaustion/manifest.json and tst_budgetcorpustest.cpp. +add_executable(UnitTestsBudgetCorpus + tst_budgetcorpustest.cpp +) +target_link_libraries(UnitTestsBudgetCorpus PRIVATE LoopLibCore Qt6::Core Qt6::Gui Qt6::Test) +target_compile_definitions(UnitTestsBudgetCorpus PRIVATE + BUDGET_CORPUS_DIR="${CMAKE_SOURCE_DIR}/UnitTests/testdata/budget_exhaustion" +) +set_target_properties(UnitTestsBudgetCorpus PROPERTIES + WIN32_EXECUTABLE OFF + MACOSX_BUNDLE OFF + LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${LOOP_INSTALL_LIB_DIR} + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${LOOP_INSTALL_BIN_DIR} +) +add_test(UnitTestsBudgetCorpus "${CMAKE_BINARY_DIR}/${LOOP_INSTALL_BIN_DIR}/UnitTestsBudgetCorpus") + add_executable(UnitTestsResourceBudget tst_resourcebudgettest.cpp ) diff --git a/UnitTests/phase4-tests.cmake b/UnitTests/phase4-tests.cmake index d3fb34835..ddc0bc762 100644 --- a/UnitTests/phase4-tests.cmake +++ b/UnitTests/phase4-tests.cmake @@ -214,6 +214,21 @@ if(NOT LOOP_BUILD_ONLY_CORE_LIBRARY) add_test(UnitTestsP4S9Interaction "${CMAKE_BINARY_DIR}/${LOOP_INSTALL_BIN_DIR}/UnitTestsP4S9Interaction") if(LOOP_BUILD_QUICK_CANVAS) + add_executable(UnitTestsQuickDocumentModel + tst_quickdocumentmodeltest.cpp + ) + + target_link_libraries(UnitTestsQuickDocumentModel PRIVATE LoopEditorQuick LoopLibCore Qt6::Core Qt6::Gui Qt6::Qml Qt6::Quick Qt6::Test) + + set_target_properties(UnitTestsQuickDocumentModel PROPERTIES + WIN32_EXECUTABLE OFF + MACOSX_BUNDLE OFF + LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${LOOP_INSTALL_LIB_DIR} + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${LOOP_INSTALL_BIN_DIR} + ) + add_test(UnitTestsQuickDocumentModel "${CMAKE_BINARY_DIR}/${LOOP_INSTALL_BIN_DIR}/UnitTestsQuickDocumentModel") + set_tests_properties(UnitTestsQuickDocumentModel PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + add_executable(UnitTestsQuickAccessibility tst_quickaccessibilitytest.cpp ) diff --git a/UnitTests/testdata/budget_exhaustion/cumulative_decoded_bytes.pdf b/UnitTests/testdata/budget_exhaustion/cumulative_decoded_bytes.pdf new file mode 100644 index 000000000..d7fd0984d Binary files /dev/null and b/UnitTests/testdata/budget_exhaustion/cumulative_decoded_bytes.pdf differ diff --git a/UnitTests/testdata/budget_exhaustion/decompression_bomb.pdf b/UnitTests/testdata/budget_exhaustion/decompression_bomb.pdf new file mode 100644 index 000000000..b6f81687d Binary files /dev/null and b/UnitTests/testdata/budget_exhaustion/decompression_bomb.pdf differ diff --git a/UnitTests/testdata/budget_exhaustion/deep_nested_content_streams.pdf b/UnitTests/testdata/budget_exhaustion/deep_nested_content_streams.pdf new file mode 100644 index 000000000..54f2c5a07 Binary files /dev/null and b/UnitTests/testdata/budget_exhaustion/deep_nested_content_streams.pdf differ diff --git a/UnitTests/testdata/budget_exhaustion/deep_recursive_object_graph.pdf b/UnitTests/testdata/budget_exhaustion/deep_recursive_object_graph.pdf new file mode 100644 index 000000000..7efc34e21 Binary files /dev/null and b/UnitTests/testdata/budget_exhaustion/deep_recursive_object_graph.pdf differ diff --git a/UnitTests/testdata/budget_exhaustion/long_running_render_work.pdf b/UnitTests/testdata/budget_exhaustion/long_running_render_work.pdf new file mode 100644 index 000000000..0e7e24219 Binary files /dev/null and b/UnitTests/testdata/budget_exhaustion/long_running_render_work.pdf differ diff --git a/UnitTests/testdata/budget_exhaustion/manifest.json b/UnitTests/testdata/budget_exhaustion/manifest.json new file mode 100644 index 000000000..5cdd6d67d --- /dev/null +++ b/UnitTests/testdata/budget_exhaustion/manifest.json @@ -0,0 +1,152 @@ +{ + "cases": [ + { + "description": "A single page whose content stream is a FlateDecode decompression bomb: 200000 bytes of highly compressible payload compress to 218 bytes (ratio ~917:1).", + "expected": { + "kind": "decompression-ratio", + "pool": "decoded-streams" + }, + "id": "decompression-bomb", + "limits": { + "maxDecompressionRatio": 40 + }, + "path": "session", + "pdf": "decompression_bomb.pdf", + "profile": { + "checks": [ + { + "id": "color-inventory", + "severity": "info" + } + ], + "name": "budget-corpus-decompression-bomb" + }, + "sha256": "cb56164642d3476d6233909d96f251db5444445056c3b30fa07f31bcb05bf703" + }, + { + "description": "12 pages, each with an unfiltered ~1012 byte content stream; no single stream is large, but the cumulative decoded total across the document exceeds a tightened cap.", + "expected": { + "kind": "cumulative-decoded-bytes", + "pool": "decoded-streams" + }, + "id": "cumulative-decoded-bytes", + "limits": { + "maxCumulativeDecodedBytes": 6000 + }, + "path": "session", + "pdf": "cumulative_decoded_bytes.pdf", + "profile": { + "checks": [ + { + "id": "color-inventory", + "severity": "info" + } + ], + "name": "budget-corpus-cumulative-decoded-bytes" + }, + "sha256": "c6c36385834fa6e5005efb820b2ffaed758cac8ebc062ff2ef9cdef94271365a" + }, + { + "description": "A page whose content stream invokes a chain of 12 nested Form XObjects (each drawing the next via the Do operator).", + "expected": { + "kind": "recursive-content-depth", + "pool": "document-model" + }, + "id": "deep-nested-content-streams", + "limits": { + "maxRecursiveContentDepth": 4 + }, + "path": "session", + "pdf": "deep_nested_content_streams.pdf", + "profile": { + "checks": [ + { + "id": "color-inventory", + "severity": "info" + } + ], + "name": "budget-corpus-deep-nested-content-streams" + }, + "sha256": "8474f1c5fc7cc79a9b972255983ae6d9306260f54a538fb35b9a72beb6bed425" + }, + { + "description": "A page content stream with 200 operator/operand tokens -- a stand-in for a pathologically operation-heavy page that would otherwise take an unbounded amount of processing to finish rendering.", + "expected": { + "kind": "render-operations", + "pool": "raster-tile" + }, + "id": "long-running-render-work", + "limits": { + "maxRenderOperations": 40 + }, + "path": "session", + "pdf": "long_running_render_work.pdf", + "profile": { + "checks": [ + { + "id": "color-inventory", + "severity": "info" + } + ], + "name": "budget-corpus-long-running-render-work" + }, + "sha256": "2f0fc6ceb8a29daf30e0431be53a7deb867bb81870ee0ff70ce78dba04026e35" + }, + { + "description": "A page with a 4000x4000pt declared MediaBox and a single fill spanning it; the 'thin-parts' check's raster probe is configured with an unreachably small pixel budget.", + "expected": { + "kind": "render-pixels", + "pool": "raster-tile" + }, + "id": "raster-probe-pixel-budget", + "limits": {}, + "path": "session", + "pdf": "raster_probe_pixel_budget.pdf", + "profile": { + "checks": [ + { + "classes": [ + "thin-fill" + ], + "id": "thin-parts", + "max_raster_pixels": 4, + "min_effective_width_pt": 0.25, + "probe_dpi": 150, + "severity": "info" + } + ], + "name": "budget-corpus-raster-probe-pixel-budget" + }, + "sha256": "dc50324b1c3e4308406b15c03f0406db7bd4c62769fa564d59a165d7a2f9cdec" + }, + { + "description": "An otherwise-ordinary document with one extra indirect object whose value is a 40-level nested array literal, unreachable from the catalog -- every occupied xref entry is still parsed.", + "expected": { + "kind": "object-depth", + "pool": "document-model" + }, + "id": "deep-recursive-object-graph", + "limits": { + "maxObjectDepth": 20 + }, + "path": "reader", + "pdf": "deep_recursive_object_graph.pdf", + "sha256": "3a55933ef0bca9c4e6f76a05cb05171d474261d5230439ab7f195a3dba0426c6" + }, + { + "description": "An otherwise-ordinary document plus 120 trivial extra indirect objects (unreachable from the catalog) driving the document's total visited-object count past a tightened cap.", + "expected": { + "kind": "objects-visited", + "pool": "document-model" + }, + "id": "pathological-object-count", + "limits": { + "maxObjectsVisited": 60 + }, + "path": "reader", + "pdf": "pathological_object_count.pdf", + "sha256": "daf3f8f4c51ac715963dd9f8cef1bef38dbccae563d37bbffe609c9f5b78e372" + } + ], + "schema_version": 1 +} diff --git a/UnitTests/testdata/budget_exhaustion/pathological_object_count.pdf b/UnitTests/testdata/budget_exhaustion/pathological_object_count.pdf new file mode 100644 index 000000000..3ef3f6b95 Binary files /dev/null and b/UnitTests/testdata/budget_exhaustion/pathological_object_count.pdf differ diff --git a/UnitTests/testdata/budget_exhaustion/raster_probe_pixel_budget.pdf b/UnitTests/testdata/budget_exhaustion/raster_probe_pixel_budget.pdf new file mode 100644 index 000000000..6c8165982 Binary files /dev/null and b/UnitTests/testdata/budget_exhaustion/raster_probe_pixel_budget.pdf differ diff --git a/UnitTests/testdata/interaction-traces/click-select-deselect.json b/UnitTests/testdata/interaction-traces/click-select-deselect.json new file mode 100644 index 000000000..9f97292e9 --- /dev/null +++ b/UnitTests/testdata/interaction-traces/click-select-deselect.json @@ -0,0 +1,231 @@ +{ + "schema_kind": "loop-interaction-scenario", + "schema_version": 1, + "scenario_id": "click-select-deselect", + "description": "Click selects the finding under the pointer; a second click on empty page space clears it. Both presses stay under the drag threshold, so neither may emit a document operation.", + "fixture": { + "page_count": 4, + "page_size_mm": { + "width": 210.0, + "height": 297.0 + }, + "pixel_per_mm": 2.0, + "device_pixel_ratio": 1.0, + "initial_zoom": 1.0, + "viewport_size_px": { + "width": 1200, + "height": 800 + }, + "page_layout": "single-page", + "hit_targets": [ + { + "kind": "finding", + "page_index": 0, + "id": "f-0001", + "page_bounds": { + "x": 20.0, + "y": 20.0, + "width": 40.0, + "height": 20.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0002", + "page_bounds": { + "x": 90.0, + "y": 20.0, + "width": 30.0, + "height": 30.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0003", + "page_bounds": { + "x": 20.0, + "y": 80.0, + "width": 60.0, + "height": 15.0 + } + } + ] + }, + "cost_model": { + "base_frame_ns": 500000, + "hit_test_ns_per_candidate": 20000, + "overlay_ns_per_primitive": 3000, + "page_surface_admit_ns": 400000, + "cache_miss_ns": 2000000, + "external_present_ns": 1500000 + }, + "budgets": { + "refresh_rate_hz": 60.0, + "frame_p95_ms": 16.667, + "input_to_frame_p95_ms": 16.667, + "max_slow_frames": 0, + "max_dropped_frames": 0, + "variance_band_multiplier": 2.5 + }, + "expected": { + "selected_id": "", + "hover_id": "", + "drag_completed": 0, + "request_generation_changed": false, + "cancellations": [], + "unbalanced_frames": 0, + "pending_inputs": 0 + }, + "trace": { + "schema_version": 1, + "trace_id": "click-select-deselect", + "inputs": [ + { + "pointer": { + "stamp": { + "monotonic_ns": 16666667, + "sequence": 1 + }, + "action": "move", + "position_px": { + "x": 300, + "y": 200 + }, + "button": 0, + "buttons": 0, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 33333334, + "sequence": 2 + }, + "action": "move", + "position_px": { + "x": 200, + "y": 120 + }, + "button": 0, + "buttons": 0, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 50000001, + "sequence": 3 + }, + "action": "move", + "position_px": { + "x": 110, + "y": 70 + }, + "button": 0, + "buttons": 0, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 66666668, + "sequence": 4 + }, + "action": "press", + "position_px": { + "x": 110, + "y": 70 + }, + "button": 1, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 83333335, + "sequence": 5 + }, + "action": "release", + "position_px": { + "x": 110, + "y": 70 + }, + "button": 1, + "buttons": 0, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 100000002, + "sequence": 6 + }, + "action": "move", + "position_px": { + "x": 300, + "y": 400 + }, + "button": 0, + "buttons": 0, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 116666669, + "sequence": 7 + }, + "action": "move", + "position_px": { + "x": 500, + "y": 520 + }, + "button": 0, + "buttons": 0, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 133333336, + "sequence": 8 + }, + "action": "press", + "position_px": { + "x": 500, + "y": 520 + }, + "button": 1, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 150000003, + "sequence": 9 + }, + "action": "release", + "position_px": { + "x": 500, + "y": 520 + }, + "button": 1, + "buttons": 0, + "modifiers": 0 + } + } + ] + } +} diff --git a/UnitTests/testdata/interaction-traces/drag-no-snap.json b/UnitTests/testdata/interaction-traces/drag-no-snap.json new file mode 100644 index 000000000..bf40f4e01 --- /dev/null +++ b/UnitTests/testdata/interaction-traces/drag-no-snap.json @@ -0,0 +1,265 @@ +{ + "schema_kind": "loop-interaction-scenario", + "schema_version": 1, + "scenario_id": "drag-no-snap", + "description": "Drag a finding with snapping off. Asserts the grab offset taken at the press survives the whole gesture, so the object does not jump its corner to the cursor on the first move, and that exactly one operation is emitted on release.", + "fixture": { + "page_count": 4, + "page_size_mm": { + "width": 210.0, + "height": 297.0 + }, + "pixel_per_mm": 2.0, + "device_pixel_ratio": 1.0, + "initial_zoom": 1.0, + "viewport_size_px": { + "width": 1200, + "height": 800 + }, + "page_layout": "single-page", + "hit_targets": [ + { + "kind": "finding", + "page_index": 0, + "id": "f-0001", + "page_bounds": { + "x": 20.0, + "y": 20.0, + "width": 40.0, + "height": 20.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0002", + "page_bounds": { + "x": 90.0, + "y": 20.0, + "width": 30.0, + "height": 30.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0003", + "page_bounds": { + "x": 20.0, + "y": 80.0, + "width": 60.0, + "height": 15.0 + } + } + ], + "snapping": { + "enabled": false + } + }, + "cost_model": { + "base_frame_ns": 500000, + "hit_test_ns_per_candidate": 20000, + "overlay_ns_per_primitive": 3000, + "page_surface_admit_ns": 400000, + "cache_miss_ns": 2000000, + "external_present_ns": 1500000 + }, + "budgets": { + "refresh_rate_hz": 60.0, + "frame_p95_ms": 16.667, + "input_to_frame_p95_ms": 16.667, + "max_slow_frames": 0, + "max_dropped_frames": 0, + "variance_band_multiplier": 2.5 + }, + "expected": { + "selected_id": "f-0001", + "drag_completed": 1, + "snapped_to": "", + "cancellations": [], + "unbalanced_frames": 0, + "pending_inputs": 0 + }, + "trace": { + "schema_version": 1, + "trace_id": "drag-no-snap", + "inputs": [ + { + "pointer": { + "stamp": { + "monotonic_ns": 16666667, + "sequence": 1 + }, + "action": "move", + "position_px": { + "x": 110, + "y": 70 + }, + "button": 0, + "buttons": 0, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 33333334, + "sequence": 2 + }, + "action": "press", + "position_px": { + "x": 110, + "y": 70 + }, + "button": 1, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 50000001, + "sequence": 3 + }, + "action": "move", + "position_px": { + "x": 130, + "y": 70 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 66666668, + "sequence": 4 + }, + "action": "move", + "position_px": { + "x": 150, + "y": 75 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 83333335, + "sequence": 5 + }, + "action": "move", + "position_px": { + "x": 170, + "y": 80 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 100000002, + "sequence": 6 + }, + "action": "move", + "position_px": { + "x": 190, + "y": 85 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 116666669, + "sequence": 7 + }, + "action": "move", + "position_px": { + "x": 210, + "y": 90 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 133333336, + "sequence": 8 + }, + "action": "move", + "position_px": { + "x": 230, + "y": 95 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 150000003, + "sequence": 9 + }, + "action": "move", + "position_px": { + "x": 250, + "y": 100 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 166666670, + "sequence": 10 + }, + "action": "move", + "position_px": { + "x": 270, + "y": 105 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 183333337, + "sequence": 11 + }, + "action": "release", + "position_px": { + "x": 270, + "y": 105 + }, + "button": 1, + "buttons": 0, + "modifiers": 0 + } + } + ] + } +} diff --git a/UnitTests/testdata/interaction-traces/drag-snap.json b/UnitTests/testdata/interaction-traces/drag-snap.json new file mode 100644 index 000000000..fc8d0a4a4 --- /dev/null +++ b/UnitTests/testdata/interaction-traces/drag-snap.json @@ -0,0 +1,290 @@ +{ + "schema_kind": "loop-interaction-scenario", + "schema_version": 1, + "scenario_id": "drag-snap", + "description": "Drag a finding past a guide with snapping on, then hold Alt. Asserts the preview latches to the guide while unmodified and releases it under Alt, and that the snap the user watched is the one recorded on the session.", + "fixture": { + "page_count": 4, + "page_size_mm": { + "width": 210.0, + "height": 297.0 + }, + "pixel_per_mm": 2.0, + "device_pixel_ratio": 1.0, + "initial_zoom": 1.0, + "viewport_size_px": { + "width": 1200, + "height": 800 + }, + "page_layout": "single-page", + "hit_targets": [ + { + "kind": "finding", + "page_index": 0, + "id": "f-0001", + "page_bounds": { + "x": 20.0, + "y": 20.0, + "width": 40.0, + "height": 20.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0002", + "page_bounds": { + "x": 90.0, + "y": 20.0, + "width": 30.0, + "height": 30.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0003", + "page_bounds": { + "x": 20.0, + "y": 80.0, + "width": 60.0, + "height": 15.0 + } + } + ], + "guides": [ + { + "id": "guide-3", + "page_index": 0, + "orientation": "vertical", + "position": 100.0 + } + ], + "snapping": { + "enabled": true, + "screen_threshold_px": 8.0 + } + }, + "cost_model": { + "base_frame_ns": 500000, + "hit_test_ns_per_candidate": 20000, + "overlay_ns_per_primitive": 3000, + "page_surface_admit_ns": 400000, + "cache_miss_ns": 2000000, + "external_present_ns": 1500000 + }, + "budgets": { + "refresh_rate_hz": 60.0, + "frame_p95_ms": 16.667, + "input_to_frame_p95_ms": 16.667, + "max_slow_frames": 0, + "max_dropped_frames": 0, + "variance_band_multiplier": 2.5 + }, + "expected": { + "selected_id": "f-0001", + "drag_completed": 1, + "snapped_to": "", + "cancellations": [], + "unbalanced_frames": 0, + "pending_inputs": 0 + }, + "trace": { + "schema_version": 1, + "trace_id": "drag-snap", + "inputs": [ + { + "pointer": { + "stamp": { + "monotonic_ns": 16666667, + "sequence": 1 + }, + "action": "move", + "position_px": { + "x": 110, + "y": 70 + }, + "button": 0, + "buttons": 0, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 33333334, + "sequence": 2 + }, + "action": "press", + "position_px": { + "x": 110, + "y": 70 + }, + "button": 1, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 50000001, + "sequence": 3 + }, + "action": "move", + "position_px": { + "x": 130, + "y": 70 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 66666668, + "sequence": 4 + }, + "action": "move", + "position_px": { + "x": 150, + "y": 70 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 83333335, + "sequence": 5 + }, + "action": "move", + "position_px": { + "x": 170, + "y": 70 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 100000002, + "sequence": 6 + }, + "action": "move", + "position_px": { + "x": 190, + "y": 70 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 116666669, + "sequence": 7 + }, + "action": "move", + "position_px": { + "x": 210, + "y": 70 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 133333336, + "sequence": 8 + }, + "action": "move", + "position_px": { + "x": 230, + "y": 70 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 150000003, + "sequence": 9 + }, + "action": "move", + "position_px": { + "x": 250, + "y": 70 + }, + "button": 0, + "buttons": 1, + "modifiers": 134217728 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 166666670, + "sequence": 10 + }, + "action": "move", + "position_px": { + "x": 270, + "y": 70 + }, + "button": 0, + "buttons": 1, + "modifiers": 134217728 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 183333337, + "sequence": 11 + }, + "action": "move", + "position_px": { + "x": 290, + "y": 70 + }, + "button": 0, + "buttons": 1, + "modifiers": 134217728 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 200000004, + "sequence": 12 + }, + "action": "release", + "position_px": { + "x": 290, + "y": 70 + }, + "button": 1, + "buttons": 0, + "modifiers": 134217728 + } + } + ] + } +} diff --git a/UnitTests/testdata/interaction-traces/hover-dense.json b/UnitTests/testdata/interaction-traces/hover-dense.json new file mode 100644 index 000000000..a797fbbf5 --- /dev/null +++ b/UnitTests/testdata/interaction-traces/hover-dense.json @@ -0,0 +1,82 @@ +{ + "schema_kind": "loop-interaction-scenario", + "schema_version": 1, + "scenario_id": "hover-dense", + "description": "Pointer sweeps across a page holding 4000 findings. The assertion that matters is the index candidate count, not latency: a spatial index that stopped narrowing looks identical to a heavier page in a frame-time percentile.", + "fixture": { + "page_count": 4, + "page_size_mm": { + "width": 210.0, + "height": 297.0 + }, + "pixel_per_mm": 2.0, + "device_pixel_ratio": 1.0, + "initial_zoom": 1.0, + "viewport_size_px": { + "width": 1200, + "height": 800 + }, + "page_layout": "single-page", + "generated_targets": { + "kind": "finding", + "count": 4000, + "page_index": 0, + "id_prefix": "d-", + "grid": { + "columns": 80, + "rows": 50, + "origin": { + "x": 5.0, + "y": 5.0 + }, + "stride": { + "width": 2.5, + "height": 5.8 + }, + "size": { + "width": 2.0, + "height": 4.0 + } + } + } + }, + "cost_model": { + "base_frame_ns": 500000, + "hit_test_ns_per_candidate": 20000, + "overlay_ns_per_primitive": 3000, + "page_surface_admit_ns": 400000, + "cache_miss_ns": 2000000, + "external_present_ns": 1500000 + }, + "budgets": { + "refresh_rate_hz": 60.0, + "frame_p95_ms": 16.667, + "input_to_frame_p95_ms": 16.667, + "max_slow_frames": 0, + "max_dropped_frames": 0, + "variance_band_multiplier": 2.5 + }, + "expected": { + "selected_id": "", + "drag_completed": 0, + "request_generation_changed": false, + "cancellations": [], + "unbalanced_frames": 0, + "pending_inputs": 0 + }, + "input_script": [ + { + "kind": "hover-sweep", + "at_px": { + "x": 30, + "y": 200 + }, + "to_px": { + "x": 780, + "y": 200 + }, + "steps": 200, + "interval_ns": 16666667 + } + ] +} diff --git a/UnitTests/testdata/interaction-traces/hover-sparse.json b/UnitTests/testdata/interaction-traces/hover-sparse.json new file mode 100644 index 000000000..6818c05a6 --- /dev/null +++ b/UnitTests/testdata/interaction-traces/hover-sparse.json @@ -0,0 +1,96 @@ +{ + "schema_kind": "loop-interaction-scenario", + "schema_version": 1, + "scenario_id": "hover-sparse", + "description": "Pointer sweeps left to right across a page holding three findings. Establishes the sparse-page candidate-count and latency baseline that hover-dense is compared against.", + "fixture": { + "page_count": 4, + "page_size_mm": { + "width": 210.0, + "height": 297.0 + }, + "pixel_per_mm": 2.0, + "device_pixel_ratio": 1.0, + "initial_zoom": 1.0, + "viewport_size_px": { + "width": 1200, + "height": 800 + }, + "page_layout": "single-page", + "hit_targets": [ + { + "kind": "finding", + "page_index": 0, + "id": "f-0001", + "page_bounds": { + "x": 20.0, + "y": 20.0, + "width": 40.0, + "height": 20.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0002", + "page_bounds": { + "x": 90.0, + "y": 20.0, + "width": 30.0, + "height": 30.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0003", + "page_bounds": { + "x": 20.0, + "y": 80.0, + "width": 60.0, + "height": 15.0 + } + } + ] + }, + "cost_model": { + "base_frame_ns": 500000, + "hit_test_ns_per_candidate": 20000, + "overlay_ns_per_primitive": 3000, + "page_surface_admit_ns": 400000, + "cache_miss_ns": 2000000, + "external_present_ns": 1500000 + }, + "budgets": { + "refresh_rate_hz": 60.0, + "frame_p95_ms": 16.667, + "input_to_frame_p95_ms": 16.667, + "max_slow_frames": 0, + "max_dropped_frames": 0, + "variance_band_multiplier": 2.5 + }, + "expected": { + "hover_id": "", + "selected_id": "", + "drag_completed": 0, + "request_generation_changed": false, + "cancellations": [], + "unbalanced_frames": 0, + "pending_inputs": 0 + }, + "input_script": [ + { + "kind": "hover-sweep", + "at_px": { + "x": 40, + "y": 60 + }, + "to_px": { + "x": 700, + "y": 60 + }, + "steps": 120, + "interval_ns": 16666667 + } + ] +} diff --git a/UnitTests/testdata/interaction-traces/manifest.json b/UnitTests/testdata/interaction-traces/manifest.json new file mode 100644 index 000000000..e6dc308d0 --- /dev/null +++ b/UnitTests/testdata/interaction-traces/manifest.json @@ -0,0 +1,89 @@ +{ + "schema_kind": "loop-interaction-corpus", + "schema_version": 1, + "scenarios": [ + { + "id": "click-select-deselect", + "path": "UnitTests/testdata/interaction-traces/click-select-deselect.json", + "issue": 146, + "sha256": "928deef8f2bdc35e2b385c379065888728e5c6b402b9969aa7d8944f6d9e5d56", + "lanes": [ + "deterministic" + ] + }, + { + "id": "drag-no-snap", + "path": "UnitTests/testdata/interaction-traces/drag-no-snap.json", + "issue": 146, + "sha256": "111753d405946cfd2c8e1a1679a717c2e8aa0cd67c2f57ed04535844b8280083", + "lanes": [ + "deterministic" + ] + }, + { + "id": "drag-snap", + "path": "UnitTests/testdata/interaction-traces/drag-snap.json", + "issue": 146, + "sha256": "d903f432aa78be1b64eda760ae41858ca7c734fac116f2eb95c32b8e19f445d5", + "lanes": [ + "deterministic" + ], + "blocked_on": "gh-488", + "blocked_reason": "DragSnapper and the Alt-suppression rule land in PR #488" + }, + { + "id": "hover-dense", + "path": "UnitTests/testdata/interaction-traces/hover-dense.json", + "issue": 146, + "sha256": "bba2c6312f4a181fff43a2a3d333efc1d6a9069753f02e56fe44ff1a31a33bcd", + "lanes": [ + "deterministic" + ] + }, + { + "id": "hover-sparse", + "path": "UnitTests/testdata/interaction-traces/hover-sparse.json", + "issue": 146, + "sha256": "393f59fd4beb8cd7c5eec5e00a7a222214a46087ce9827488cac53589aa9a1a4", + "lanes": [ + "deterministic" + ] + }, + { + "id": "overlay-dense", + "path": "UnitTests/testdata/interaction-traces/overlay-dense.json", + "issue": 146, + "sha256": "55a0b5c35eae3a1fadda39dcec2f4a1d9f6f644b33c5cb021c1180d1e90ce8b5", + "lanes": [ + "deterministic" + ] + }, + { + "id": "pan", + "path": "UnitTests/testdata/interaction-traces/pan.json", + "issue": 146, + "sha256": "c43200db0b9aedfbaab7730b75ef0ae3e1909bed355b105ea1c7771aea341244", + "lanes": [ + "deterministic" + ] + }, + { + "id": "zoom-anchored", + "path": "UnitTests/testdata/interaction-traces/zoom-anchored.json", + "issue": 146, + "sha256": "0089819a19f41c14574eae79a66b4cb85240e61b253df39fb9ef239556637862", + "lanes": [ + "deterministic" + ] + }, + { + "id": "zoom-reversal", + "path": "UnitTests/testdata/interaction-traces/zoom-reversal.json", + "issue": 146, + "sha256": "28c6fc63971eb59d5a00ba12266dad4f846ebbc5a5468a51b172ce756646a86a", + "lanes": [ + "deterministic" + ] + } + ] +} diff --git a/UnitTests/testdata/interaction-traces/overlay-dense.json b/UnitTests/testdata/interaction-traces/overlay-dense.json new file mode 100644 index 000000000..ed3e24ead --- /dev/null +++ b/UnitTests/testdata/interaction-traces/overlay-dense.json @@ -0,0 +1,324 @@ +{ + "schema_kind": "loop-interaction-scenario", + "schema_version": 1, + "scenario_id": "overlay-dense", + "description": "Hover over a page carrying far more findings and guides than the overlay bounds admit. Asserts the frame stays bounded at maxPrimitives and reports what it dropped, rather than growing without limit or aborting the pass.", + "fixture": { + "page_count": 4, + "page_size_mm": { + "width": 210.0, + "height": 297.0 + }, + "pixel_per_mm": 2.0, + "device_pixel_ratio": 1.0, + "initial_zoom": 1.0, + "viewport_size_px": { + "width": 1200, + "height": 800 + }, + "page_layout": "single-page", + "generated_targets": { + "kind": "finding", + "count": 6000, + "page_index": 0, + "id_prefix": "o-", + "grid": { + "columns": 100, + "rows": 60, + "origin": { + "x": 2.0, + "y": 2.0 + }, + "stride": { + "width": 2.0, + "height": 4.8 + }, + "size": { + "width": 1.5, + "height": 3.0 + } + } + }, + "guides": [ + { + "id": "g-000", + "page_index": 0, + "orientation": "horizontal", + "position": 5.0 + }, + { + "id": "g-001", + "page_index": 0, + "orientation": "vertical", + "position": 7.0 + }, + { + "id": "g-002", + "page_index": 0, + "orientation": "horizontal", + "position": 9.0 + }, + { + "id": "g-003", + "page_index": 0, + "orientation": "vertical", + "position": 11.0 + }, + { + "id": "g-004", + "page_index": 0, + "orientation": "horizontal", + "position": 13.0 + }, + { + "id": "g-005", + "page_index": 0, + "orientation": "vertical", + "position": 15.0 + }, + { + "id": "g-006", + "page_index": 0, + "orientation": "horizontal", + "position": 17.0 + }, + { + "id": "g-007", + "page_index": 0, + "orientation": "vertical", + "position": 19.0 + }, + { + "id": "g-008", + "page_index": 0, + "orientation": "horizontal", + "position": 21.0 + }, + { + "id": "g-009", + "page_index": 0, + "orientation": "vertical", + "position": 23.0 + }, + { + "id": "g-010", + "page_index": 0, + "orientation": "horizontal", + "position": 25.0 + }, + { + "id": "g-011", + "page_index": 0, + "orientation": "vertical", + "position": 27.0 + }, + { + "id": "g-012", + "page_index": 0, + "orientation": "horizontal", + "position": 29.0 + }, + { + "id": "g-013", + "page_index": 0, + "orientation": "vertical", + "position": 31.0 + }, + { + "id": "g-014", + "page_index": 0, + "orientation": "horizontal", + "position": 33.0 + }, + { + "id": "g-015", + "page_index": 0, + "orientation": "vertical", + "position": 35.0 + }, + { + "id": "g-016", + "page_index": 0, + "orientation": "horizontal", + "position": 37.0 + }, + { + "id": "g-017", + "page_index": 0, + "orientation": "vertical", + "position": 39.0 + }, + { + "id": "g-018", + "page_index": 0, + "orientation": "horizontal", + "position": 41.0 + }, + { + "id": "g-019", + "page_index": 0, + "orientation": "vertical", + "position": 43.0 + }, + { + "id": "g-020", + "page_index": 0, + "orientation": "horizontal", + "position": 45.0 + }, + { + "id": "g-021", + "page_index": 0, + "orientation": "vertical", + "position": 47.0 + }, + { + "id": "g-022", + "page_index": 0, + "orientation": "horizontal", + "position": 49.0 + }, + { + "id": "g-023", + "page_index": 0, + "orientation": "vertical", + "position": 51.0 + }, + { + "id": "g-024", + "page_index": 0, + "orientation": "horizontal", + "position": 53.0 + }, + { + "id": "g-025", + "page_index": 0, + "orientation": "vertical", + "position": 55.0 + }, + { + "id": "g-026", + "page_index": 0, + "orientation": "horizontal", + "position": 57.0 + }, + { + "id": "g-027", + "page_index": 0, + "orientation": "vertical", + "position": 59.0 + }, + { + "id": "g-028", + "page_index": 0, + "orientation": "horizontal", + "position": 61.0 + }, + { + "id": "g-029", + "page_index": 0, + "orientation": "vertical", + "position": 63.0 + }, + { + "id": "g-030", + "page_index": 0, + "orientation": "horizontal", + "position": 65.0 + }, + { + "id": "g-031", + "page_index": 0, + "orientation": "vertical", + "position": 67.0 + }, + { + "id": "g-032", + "page_index": 0, + "orientation": "horizontal", + "position": 69.0 + }, + { + "id": "g-033", + "page_index": 0, + "orientation": "vertical", + "position": 71.0 + }, + { + "id": "g-034", + "page_index": 0, + "orientation": "horizontal", + "position": 73.0 + }, + { + "id": "g-035", + "page_index": 0, + "orientation": "vertical", + "position": 75.0 + }, + { + "id": "g-036", + "page_index": 0, + "orientation": "horizontal", + "position": 77.0 + }, + { + "id": "g-037", + "page_index": 0, + "orientation": "vertical", + "position": 79.0 + }, + { + "id": "g-038", + "page_index": 0, + "orientation": "horizontal", + "position": 81.0 + }, + { + "id": "g-039", + "page_index": 0, + "orientation": "vertical", + "position": 83.0 + } + ] + }, + "cost_model": { + "base_frame_ns": 500000, + "hit_test_ns_per_candidate": 20000, + "overlay_ns_per_primitive": 3000, + "page_surface_admit_ns": 400000, + "cache_miss_ns": 2000000, + "external_present_ns": 1500000 + }, + "budgets": { + "refresh_rate_hz": 60.0, + "frame_p95_ms": 16.667, + "input_to_frame_p95_ms": 16.667, + "max_slow_frames": 0, + "max_dropped_frames": 0, + "variance_band_multiplier": 2.5 + }, + "expected": { + "selected_id": "", + "drag_completed": 0, + "request_generation_changed": false, + "cancellations": [], + "unbalanced_frames": 0, + "pending_inputs": 0 + }, + "input_script": [ + { + "kind": "hover-sweep", + "at_px": { + "x": 40, + "y": 150 + }, + "to_px": { + "x": 760, + "y": 450 + }, + "steps": 150, + "interval_ns": 16666667 + } + ] +} diff --git a/UnitTests/testdata/interaction-traces/pan.json b/UnitTests/testdata/interaction-traces/pan.json new file mode 100644 index 000000000..175094d56 --- /dev/null +++ b/UnitTests/testdata/interaction-traces/pan.json @@ -0,0 +1,111 @@ +{ + "schema_kind": "loop-interaction-scenario", + "schema_version": 1, + "scenario_id": "pan", + "description": "Middle-button drag pans the viewport. Asserts the viewport request generation does not advance, which is issue #142's rule that a pan must not cancel in-flight page renders.", + "fixture": { + "page_count": 4, + "page_size_mm": { + "width": 210.0, + "height": 297.0 + }, + "pixel_per_mm": 2.0, + "device_pixel_ratio": 1.0, + "initial_zoom": 1.0, + "viewport_size_px": { + "width": 1200, + "height": 800 + }, + "page_layout": "single-page", + "hit_targets": [ + { + "kind": "finding", + "page_index": 0, + "id": "f-0001", + "page_bounds": { + "x": 20.0, + "y": 20.0, + "width": 40.0, + "height": 20.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0002", + "page_bounds": { + "x": 90.0, + "y": 20.0, + "width": 30.0, + "height": 30.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0003", + "page_bounds": { + "x": 20.0, + "y": 80.0, + "width": 60.0, + "height": 15.0 + } + } + ] + }, + "cost_model": { + "base_frame_ns": 500000, + "hit_test_ns_per_candidate": 20000, + "overlay_ns_per_primitive": 3000, + "page_surface_admit_ns": 400000, + "cache_miss_ns": 2000000, + "external_present_ns": 1500000 + }, + "budgets": { + "refresh_rate_hz": 60.0, + "frame_p95_ms": 16.667, + "input_to_frame_p95_ms": 16.667, + "max_slow_frames": 0, + "max_dropped_frames": 0, + "variance_band_multiplier": 2.5 + }, + "expected": { + "selected_id": "", + "drag_completed": 0, + "request_generation_changed": false, + "cancellations": [], + "unbalanced_frames": 0, + "pending_inputs": 0 + }, + "input_script": [ + { + "kind": "pointer-press", + "at_px": { + "x": 600, + "y": 400 + }, + "button": "middle" + }, + { + "kind": "hover-sweep", + "at_px": { + "x": 600, + "y": 400 + }, + "to_px": { + "x": 300, + "y": 250 + }, + "steps": 60, + "interval_ns": 16666667 + }, + { + "kind": "pointer-release", + "at_px": { + "x": 300, + "y": 250 + }, + "button": "middle" + } + ] +} diff --git a/UnitTests/testdata/interaction-traces/zoom-anchored.json b/UnitTests/testdata/interaction-traces/zoom-anchored.json new file mode 100644 index 000000000..89d79a736 --- /dev/null +++ b/UnitTests/testdata/interaction-traces/zoom-anchored.json @@ -0,0 +1,228 @@ +{ + "schema_kind": "loop-interaction-scenario", + "schema_version": 1, + "scenario_id": "zoom-anchored", + "description": "Six wheel-zoom steps anchored at one cursor position. Asserts the page point under the cursor is unchanged at the end, and that a zoom does advance the viewport request generation -- the half of issue #142's rule that pan must not.", + "fixture": { + "page_count": 4, + "page_size_mm": { + "width": 210.0, + "height": 297.0 + }, + "pixel_per_mm": 2.0, + "device_pixel_ratio": 1.0, + "initial_zoom": 1.0, + "viewport_size_px": { + "width": 1200, + "height": 800 + }, + "page_layout": "single-page", + "hit_targets": [ + { + "kind": "finding", + "page_index": 0, + "id": "f-0001", + "page_bounds": { + "x": 20.0, + "y": 20.0, + "width": 40.0, + "height": 20.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0002", + "page_bounds": { + "x": 90.0, + "y": 20.0, + "width": 30.0, + "height": 30.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0003", + "page_bounds": { + "x": 20.0, + "y": 80.0, + "width": 60.0, + "height": 15.0 + } + } + ] + }, + "cost_model": { + "base_frame_ns": 500000, + "hit_test_ns_per_candidate": 20000, + "overlay_ns_per_primitive": 3000, + "page_surface_admit_ns": 400000, + "cache_miss_ns": 2000000, + "external_present_ns": 1500000 + }, + "budgets": { + "refresh_rate_hz": 60.0, + "frame_p95_ms": 16.667, + "input_to_frame_p95_ms": 16.667, + "max_slow_frames": 0, + "max_dropped_frames": 0, + "variance_band_multiplier": 2.5 + }, + "expected": { + "zoom": 2.985984, + "request_generation_changed": true, + "drag_completed": 0, + "cancellations": [], + "unbalanced_frames": 0, + "pending_inputs": 0 + }, + "trace": { + "schema_version": 1, + "trace_id": "zoom-anchored", + "inputs": [ + { + "pointer": { + "stamp": { + "monotonic_ns": 16666667, + "sequence": 1 + }, + "action": "move", + "position_px": { + "x": 500, + "y": 350 + }, + "button": 0, + "buttons": 0, + "modifiers": 0 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 33333334, + "sequence": 2 + }, + "position_px": { + "x": 500, + "y": 350 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 50000001, + "sequence": 3 + }, + "position_px": { + "x": 500, + "y": 350 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 66666668, + "sequence": 4 + }, + "position_px": { + "x": 500, + "y": 350 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 83333335, + "sequence": 5 + }, + "position_px": { + "x": 500, + "y": 350 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 100000002, + "sequence": 6 + }, + "position_px": { + "x": 500, + "y": 350 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 116666669, + "sequence": 7 + }, + "position_px": { + "x": 500, + "y": 350 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + } + ] + } +} diff --git a/UnitTests/testdata/interaction-traces/zoom-reversal.json b/UnitTests/testdata/interaction-traces/zoom-reversal.json new file mode 100644 index 000000000..0d265cb3c --- /dev/null +++ b/UnitTests/testdata/interaction-traces/zoom-reversal.json @@ -0,0 +1,458 @@ +{ + "schema_kind": "loop-interaction-scenario", + "schema_version": 1, + "scenario_id": "zoom-reversal", + "description": "Eight zoom steps in, eight back out, then page switches. The corpus port of InteractionControllerTest::rapidZoomReversalAndPageSwitchSettleWithinTraceBudget; the C++ case stays in place as the unit-level guard.", + "fixture": { + "page_count": 4, + "page_size_mm": { + "width": 210.0, + "height": 297.0 + }, + "pixel_per_mm": 2.0, + "device_pixel_ratio": 1.0, + "initial_zoom": 1.0, + "viewport_size_px": { + "width": 1200, + "height": 800 + }, + "page_layout": "single-page", + "hit_targets": [ + { + "kind": "finding", + "page_index": 0, + "id": "f-0001", + "page_bounds": { + "x": 20.0, + "y": 20.0, + "width": 40.0, + "height": 20.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0002", + "page_bounds": { + "x": 90.0, + "y": 20.0, + "width": 30.0, + "height": 30.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0003", + "page_bounds": { + "x": 20.0, + "y": 80.0, + "width": 60.0, + "height": 15.0 + } + } + ] + }, + "cost_model": { + "base_frame_ns": 500000, + "hit_test_ns_per_candidate": 20000, + "overlay_ns_per_primitive": 3000, + "page_surface_admit_ns": 400000, + "cache_miss_ns": 2000000, + "external_present_ns": 1500000 + }, + "budgets": { + "refresh_rate_hz": 60.0, + "frame_p95_ms": 16.667, + "input_to_frame_p95_ms": 16.667, + "max_slow_frames": 0, + "max_dropped_frames": 0, + "variance_band_multiplier": 2.5 + }, + "expected": { + "zoom": 1.0, + "request_generation_changed": true, + "drag_completed": 0, + "cancellations": [], + "unbalanced_frames": 0, + "pending_inputs": 0 + }, + "trace": { + "schema_version": 1, + "trace_id": "zoom-reversal", + "inputs": [ + { + "wheel": { + "stamp": { + "monotonic_ns": 16666667, + "sequence": 1 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 33333334, + "sequence": 2 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 50000001, + "sequence": 3 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 66666668, + "sequence": 4 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 83333335, + "sequence": 5 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 100000002, + "sequence": 6 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 116666669, + "sequence": 7 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 133333336, + "sequence": 8 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 150000003, + "sequence": 9 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": -120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 166666670, + "sequence": 10 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": -120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 183333337, + "sequence": 11 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": -120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 200000004, + "sequence": 12 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": -120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 216666671, + "sequence": 13 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": -120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 233333338, + "sequence": 14 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": -120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 250000005, + "sequence": 15 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": -120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 266666672, + "sequence": 16 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": -120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "key": { + "stamp": { + "monotonic_ns": 283333339, + "sequence": 17 + }, + "action": "press", + "key": 16777238, + "modifiers": 0, + "auto_repeat": false + } + }, + { + "key": { + "stamp": { + "monotonic_ns": 300000006, + "sequence": 18 + }, + "action": "press", + "key": 16777239, + "modifiers": 0, + "auto_repeat": false + } + }, + { + "key": { + "stamp": { + "monotonic_ns": 316666673, + "sequence": 19 + }, + "action": "press", + "key": 16777238, + "modifiers": 0, + "auto_repeat": false + } + } + ] + } +} diff --git a/UnitTests/tst_budgetcorpustest.cpp b/UnitTests/tst_budgetcorpustest.cpp new file mode 100644 index 000000000..8c5119be0 --- /dev/null +++ b/UnitTests/tst_budgetcorpustest.cpp @@ -0,0 +1,354 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// gh-243: a committed corpus of synthetic adversarial PDF fixtures, one per +// pdf::PDFProcessingBudget dimension named in the issue, generated by +// scripts/resource_envelope/budget_exhaustion_corpus.py and driven from the +// manifest it writes alongside them +// (UnitTests/testdata/budget_exhaustion/manifest.json). This is the +// adversarial complement to the individual-API-call coverage in +// tst_budgetexhaustiontest.cpp: here, a real (if tiny) PDF file is read +// through pdf::PDFDocumentReader and pdf::PreflightEngine, the way a hostile +// upload actually would be, and every case asserts the run terminates within +// a bounded time and fails closed with the exact exceeded budget attributed +// -- never a hang, a crash, or a silent clean result. +// +// Manifest cases come in two shapes ("path" field): +// - "session": the fixture is a fully valid, readable document. The budget +// trips while pdf::PreflightEngine walks it with one pdf::PDFProcessingLimits +// field (or, for the raster-probe fixture, one preflight check parameter) +// tightened past what the fixture's shape demands. The structured +// checks[].budget.{kind,pool,limit,attempted} fields +// (docs/RESOURCE_BUDGETS.md) are asserted directly. +// - "reader": the budget trips inside pdf::PDFDocumentReader itself, before +// a document exists (deep PDF object nesting, a pathological object +// count). pdf::PDFDocumentReader does not expose the structured +// pdf::PDFBudgetExceeded detail on failure, only its formatted message, so +// these cases parse the kind name and the "attempted N, limit M" numbers +// out of getErrorMessage() (see PDFBudgetExceededException's constructor +// in pdfprocessingbudget.cpp for the exact format). + +#include "pdfdocumentreader.h" +#include "pdfdocumentsession.h" +#include "pdfpreflightverdict.h" +#include "pdfprocessingbudget.h" +#include "preflightengine.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +// A generous ceiling: every fixture is deliberately tiny and trips its +// tightened budget almost immediately. This bounds catastrophic regressions +// (a hang, an accidental unbounded loop) without being a tight perf assertion. +constexpr int BOUNDED_MS = 20000; + +QString corpusDir() +{ + return QStringLiteral(BUDGET_CORPUS_DIR); +} + +pdf::PDFProcessingLimits limitsFromOverrides(const QJsonObject& overrides) +{ + pdf::PDFProcessingLimits limits = pdf::PDFProcessingLimits::conservativeDefaults(); + if (overrides.contains(QStringLiteral("maxDecompressionRatio"))) + { + limits.maxDecompressionRatio = static_cast(overrides.value(QStringLiteral("maxDecompressionRatio")).toDouble()); + } + if (overrides.contains(QStringLiteral("maxCumulativeDecodedBytes"))) + { + limits.maxCumulativeDecodedBytes = static_cast(overrides.value(QStringLiteral("maxCumulativeDecodedBytes")).toDouble()); + } + if (overrides.contains(QStringLiteral("maxRecursiveContentDepth"))) + { + limits.maxRecursiveContentDepth = static_cast(overrides.value(QStringLiteral("maxRecursiveContentDepth")).toInt()); + } + if (overrides.contains(QStringLiteral("maxRenderOperations"))) + { + limits.maxRenderOperations = static_cast(overrides.value(QStringLiteral("maxRenderOperations")).toDouble()); + } + if (overrides.contains(QStringLiteral("maxObjectDepth"))) + { + limits.maxObjectDepth = static_cast(overrides.value(QStringLiteral("maxObjectDepth")).toInt()); + } + if (overrides.contains(QStringLiteral("maxObjectsVisited"))) + { + limits.maxObjectsVisited = static_cast(overrides.value(QStringLiteral("maxObjectsVisited")).toDouble()); + } + return limits; +} + +// Every fixture tightens exactly one limit (or, for the raster-probe case, +// one check parameter) to the value the corpus test must observe echoed back +// as checks[].budget.limit / the reader's error message. +qint64 expectedLimitFor(const QJsonObject& testCase) +{ + const QJsonObject limitsObject = testCase.value(QStringLiteral("limits")).toObject(); + if (!limitsObject.isEmpty()) + { + return static_cast(limitsObject.constBegin().value().toDouble()); + } + + const QJsonArray checks = testCase.value(QStringLiteral("profile")).toObject().value(QStringLiteral("checks")).toArray(); + if (!checks.isEmpty()) + { + const QJsonObject check = checks.at(0).toObject(); + if (check.contains(QStringLiteral("max_raster_pixels"))) + { + return static_cast(check.value(QStringLiteral("max_raster_pixels")).toDouble()); + } + } + + return -1; +} + +QByteArray readFixture(const QString& filename) +{ + QFile file(QDir(corpusDir()).filePath(filename)); + if (!file.open(QIODevice::ReadOnly)) + { + return QByteArray(); + } + return file.readAll(); +} + +} // namespace + +class BudgetCorpusTest : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + + void sessionBudgetFixturesReportIncomplete_data(); + void sessionBudgetFixturesReportIncomplete(); + + void readerBudgetFixturesFailClosed_data(); + void readerBudgetFixturesFailClosed(); + + void everyScopedDimensionHasAFixture(); + +private: + QJsonArray m_cases; +}; + +void BudgetCorpusTest::initTestCase() +{ + const QString manifestPath = QDir(corpusDir()).filePath(QStringLiteral("manifest.json")); + QFile manifestFile(manifestPath); + QVERIFY2(manifestFile.open(QIODevice::ReadOnly), qPrintable(QStringLiteral("Cannot open manifest '%1'").arg(manifestPath))); + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(manifestFile.readAll(), &parseError); + QVERIFY2(parseError.error == QJsonParseError::NoError, qPrintable(parseError.errorString())); + QVERIFY2(document.isObject(), "manifest.json must contain a top-level JSON object"); + + m_cases = document.object().value(QStringLiteral("cases")).toArray(); + QVERIFY2(!m_cases.isEmpty(), "manifest.json has no fixture cases"); +} + +void BudgetCorpusTest::sessionBudgetFixturesReportIncomplete_data() +{ + QTest::addColumn("pdf"); + QTest::addColumn("limits"); + QTest::addColumn("profile"); + QTest::addColumn("expectedKind"); + QTest::addColumn("expectedPool"); + QTest::addColumn("expectedLimit"); + + for (const QJsonValue& value : m_cases) + { + const QJsonObject entry = value.toObject(); + if (entry.value(QStringLiteral("path")).toString() != QStringLiteral("session")) + { + continue; + } + + const QJsonObject expect = entry.value(QStringLiteral("expected")).toObject(); + const QString id = entry.value(QStringLiteral("id")).toString(); + QTest::newRow(qPrintable(id)) << entry.value(QStringLiteral("pdf")).toString() + << entry.value(QStringLiteral("limits")).toObject() + << entry.value(QStringLiteral("profile")).toObject() + << expect.value(QStringLiteral("kind")).toString() + << expect.value(QStringLiteral("pool")).toString() + << expectedLimitFor(entry); + } +} + +void BudgetCorpusTest::sessionBudgetFixturesReportIncomplete() +{ + QFETCH(QString, pdf); + QFETCH(QJsonObject, limits); + QFETCH(QJsonObject, profile); + QFETCH(QString, expectedKind); + QFETCH(QString, expectedPool); + QFETCH(qint64, expectedLimit); + + const QByteArray bytes = readFixture(pdf); + QVERIFY2(!bytes.isEmpty(), qPrintable(QStringLiteral("Cannot read fixture '%1'").arg(pdf))); + + QElapsedTimer timer; + timer.start(); + + // The fixture itself must be an ordinary, fully readable document: the + // budget under test is the session's, tightened below, not the reader's. + auto noPassword = [](bool*) + { return QString(); }; + pdf::PDFDocumentReader reader(nullptr, noPassword, false, false); + pdf::PDFDocument document = reader.readFromBuffer(bytes); + QCOMPARE(int(reader.getReadingResult()), int(pdf::PDFDocumentReader::Result::OK)); + + pdf::PDFDocumentSession session(&document); + session.setProcessingLimits(limitsFromOverrides(limits)); + + pdf::PreflightEngine engine(&session); + const pdf::PreflightResult result = engine.run(profile); + + QVERIFY2(timer.elapsed() < BOUNDED_MS, "budget fixture must terminate within the bounded time budget"); + + QVERIFY2(!result.inspectionComplete, "a budget-exceeded run must never report a complete inspection"); + + const pdf::PreflightVerdict verdict = pdf::reducePreflightVerdict(result); + QCOMPARE(int(verdict.state), int(pdf::PreflightVerdictState::Incomplete)); + QVERIFY2(!verdict.isPass(), "a budget-exceeded check must never contribute a clean (pass) verdict"); + + bool foundExpectedBudget = false; + for (const pdf::PreflightCheckStatus& status : result.checkStatuses) + { + if (status.budgetKind == expectedKind) + { + QCOMPARE(status.status, QStringLiteral("incomplete")); + QCOMPARE(status.reason, QStringLiteral("budget-exceeded")); + QCOMPARE(status.budgetPool, expectedPool); + QCOMPARE(status.budgetLimit, expectedLimit); + QVERIFY2(status.budgetAttempted > status.budgetLimit, + "attempted must exceed the configured limit"); + foundExpectedBudget = true; + break; + } + } + QVERIFY2(foundExpectedBudget, + qPrintable(QStringLiteral("No check status reported budget kind '%1'").arg(expectedKind))); +} + +void BudgetCorpusTest::readerBudgetFixturesFailClosed_data() +{ + QTest::addColumn("pdf"); + QTest::addColumn("limits"); + QTest::addColumn("expectedKind"); + QTest::addColumn("expectedLimit"); + + for (const QJsonValue& value : m_cases) + { + const QJsonObject entry = value.toObject(); + if (entry.value(QStringLiteral("path")).toString() != QStringLiteral("reader")) + { + continue; + } + + const QJsonObject expect = entry.value(QStringLiteral("expected")).toObject(); + const QString id = entry.value(QStringLiteral("id")).toString(); + QTest::newRow(qPrintable(id)) << entry.value(QStringLiteral("pdf")).toString() + << entry.value(QStringLiteral("limits")).toObject() + << expect.value(QStringLiteral("kind")).toString() + << expectedLimitFor(entry); + } +} + +void BudgetCorpusTest::readerBudgetFixturesFailClosed() +{ + QFETCH(QString, pdf); + QFETCH(QJsonObject, limits); + QFETCH(QString, expectedKind); + QFETCH(qint64, expectedLimit); + + const QByteArray bytes = readFixture(pdf); + QVERIFY2(!bytes.isEmpty(), qPrintable(QStringLiteral("Cannot read fixture '%1'").arg(pdf))); + + QElapsedTimer timer; + timer.start(); + + auto noPassword = [](bool*) + { return QString(); }; + pdf::PDFDocumentReader reader(nullptr, noPassword, false, false, limitsFromOverrides(limits)); + pdf::PDFDocument document = reader.readFromBuffer(bytes); + Q_UNUSED(document); + + QVERIFY2(timer.elapsed() < BOUNDED_MS, "budget fixture must terminate within the bounded time budget"); + + // A budget trip while reading must fail the read outright: there is no + // document yet to report an incomplete inspection about, so failing + // closed here means the reader refuses to hand back a usable document. + QCOMPARE(int(reader.getReadingResult()), int(pdf::PDFDocumentReader::Result::Failed)); + + const QString message = reader.getErrorMessage(); + QVERIFY2(message.contains(expectedKind), qPrintable(QStringLiteral("Error message '%1' does not name budget kind '%2'").arg(message, expectedKind))); + + // PDFBudgetExceededException's message is "... exceeded: attempted A, limit L (...)." + // (see PDFBudgetExceededException's constructor in pdfprocessingbudget.cpp); + // parse it out to confirm the numbers, not just the kind name, are attributable. + QRegularExpression numbers(QStringLiteral("attempted (\\d+), limit (\\d+)")); + const QRegularExpressionMatch match = numbers.match(message); + QVERIFY2(match.hasMatch(), qPrintable(QStringLiteral("Error message '%1' does not carry attempted/limit numbers").arg(message))); + QCOMPARE(match.captured(2).toLongLong(), expectedLimit); + QVERIFY2(match.captured(1).toLongLong() > match.captured(2).toLongLong(), + "attempted must exceed the configured limit"); +} + +void BudgetCorpusTest::everyScopedDimensionHasAFixture() +{ + // gh-243's scope lists seven adversarial shapes; confirm the manifest + // still names all seven distinct pdf::PDFBudgetKind values rather than + // silently losing coverage to a future edit. + QSet kinds; + for (const QJsonValue& value : m_cases) + { + kinds.insert(value.toObject().value(QStringLiteral("expected")).toObject().value(QStringLiteral("kind")).toString()); + } + + const QStringList required = { + QStringLiteral("decompression-ratio"), + QStringLiteral("cumulative-decoded-bytes"), + QStringLiteral("recursive-content-depth"), + QStringLiteral("render-operations"), + QStringLiteral("render-pixels"), + QStringLiteral("object-depth"), + QStringLiteral("objects-visited"), + }; + for (const QString& kind : required) + { + QVERIFY2(kinds.contains(kind), qPrintable(QStringLiteral("No corpus fixture trips budget kind '%1'").arg(kind))); + } +} + +QTEST_MAIN(BudgetCorpusTest) +#include "tst_budgetcorpustest.moc" diff --git a/UnitTests/tst_quickdocumentmodeltest.cpp b/UnitTests/tst_quickdocumentmodeltest.cpp new file mode 100644 index 000000000..3bf04c93f --- /dev/null +++ b/UnitTests/tst_quickdocumentmodeltest.cpp @@ -0,0 +1,104 @@ +// MIT License +#include "quickdocumentmodel.h" + +#include "pdfdocumentbuilder.h" +#include "pdfdocumentcontext.h" + +#include +#include + +class QuickDocumentModelTest final : public QObject +{ + Q_OBJECT + +private slots: + void emptyModelIsSafe(); + void searchResultsExposeOnlyValueRoles(); + void documentCapabilitiesAreValueState(); + void lifecycleStateTracksOutputAndErrors(); +}; + +void QuickDocumentModelTest::emptyModelIsSafe() +{ + QuickDocumentModel model; + + QCOMPARE(model.pages()->rowCount(), 0); + QCOMPARE(model.outline()->rowCount(), 0); + QCOMPARE(model.searchResults()->rowCount(), 0); + QVERIFY(!model.hasOutline()); + QVERIFY(!model.hasAttachments()); + QVERIFY(!model.hasOptionalContent()); + QVERIFY(!model.search(QStringLiteral("text"))); +} + +void QuickDocumentModelTest::searchResultsExposeOnlyValueRoles() +{ + QuickSearchResultModel model; + QSignalSpy resetSpy(&model, &QAbstractItemModel::modelReset); + + model.replace({ { 3, QStringLiteral("match"), QStringLiteral("before match after") } }, + QStringLiteral("match"), QStringLiteral("revision")); + + QCOMPARE(resetSpy.count(), 1); + QCOMPARE(model.rowCount(), 1); + const QModelIndex index = model.index(0, 0); + QCOMPARE(model.data(index, QuickSearchResultModel::PageRole).toInt(), 3); + QCOMPARE(model.data(index, QuickSearchResultModel::MatchedRole).toString(), QStringLiteral("match")); + QCOMPARE(model.data(index, QuickSearchResultModel::ContextRole).toString(), QStringLiteral("before match after")); + QCOMPARE(model.query(), QStringLiteral("match")); + QCOMPARE(model.revision(), QStringLiteral("revision")); +} + +void QuickDocumentModelTest::documentCapabilitiesAreValueState() +{ + pdf::PDFDocumentBuilder builder; + builder.appendPage(QRectF(0, 0, 100, 100)); + pdf::PDFDocumentContext context(pdf::PDFDocumentPointer(new pdf::PDFDocument(builder.build()))); + QuickDocumentModel model; + + QSignalSpy changedSpy(&model, &QuickDocumentModel::changed); + model.setDocument(&context); + + QVERIFY(changedSpy.count() > 0); + QCOMPARE(model.pages()->rowCount(), 1); + QVERIFY(!model.encrypted()); + QVERIFY(model.canPrint()); + QVERIFY(model.canHighResolutionPrint()); + QVERIFY(model.canCopy()); + QVERIFY(model.canModify()); + QVERIFY(model.canComment()); + QVERIFY(model.canFillForms()); + QVERIFY(model.canAssemble()); + QVERIFY(model.canAccessibility()); + QVERIFY(!model.hasForm()); + QVERIFY(!model.modified()); + QCOMPARE(model.revision(), context.getRevision().toString()); +} + +void QuickDocumentModelTest::lifecycleStateTracksOutputAndErrors() +{ + QuickDocumentModel model; + QSignalSpy changedSpy(&model, &QuickDocumentModel::changed); + + model.setLifecycleState(QStringLiteral("ready"), true, false, QStringLiteral("pending"), {}); + QVERIFY(model.modified()); + QVERIFY(!model.stale()); + QVERIFY(model.outputPending()); + QVERIFY(!model.outputSaved()); + QCOMPARE(model.lifecycleState(), QStringLiteral("ready")); + QCOMPARE(model.outputState(), QStringLiteral("pending")); + + model.setLifecycleState(QStringLiteral("ready"), false, false, QStringLiteral("saved"), {}); + QVERIFY(!model.modified()); + QVERIFY(!model.outputPending()); + QVERIFY(model.outputSaved()); + + model.setLifecycleState(QStringLiteral("error"), false, false, QStringLiteral("none"), + QStringLiteral("document/load-failed")); + QCOMPARE(model.lifecycleState(), QStringLiteral("error")); + QCOMPARE(model.typedError(), QStringLiteral("document/load-failed")); + QVERIFY(changedSpy.count() >= 3); +} + +QTEST_GUILESS_MAIN(QuickDocumentModelTest) +#include "tst_quickdocumentmodeltest.moc" diff --git a/UnitTests/tst_revisionstresstest.cpp b/UnitTests/tst_revisionstresstest.cpp new file mode 100644 index 000000000..c5a773f83 --- /dev/null +++ b/UnitTests/tst_revisionstresstest.cpp @@ -0,0 +1,592 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// Concurrent revision-authority stress scenario. +// +// tst_documentsessiontest.cpp proves the fence in isolation: one orchestrated +// round, all producers released after the mutation. This file runs the +// acceptance scenario instead - render, preflight, thumbnail, and repair-plan +// jobs in flight simultaneously while the document is mutated at points the +// producers do not observe - and asserts the four correctness properties: +// +// 1. zero stale findings applied; +// 2. zero stale tiles presented as current past an invalidation boundary; +// 3. deterministic cancellation (cancelled work is terminal, never success, +// and never publishes a result); +// 4. no cache ever returns a result for the wrong revision. +// +// "Zero stale" is a correctness requirement here, not a percentile: a single +// admitted stale result fails the test. + +#include "pdfdocumentbuilder.h" +#include "pdfdocumentcontext.h" +#include "pdfdocumentsession.h" +#include "pdfjobscheduler.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace +{ + +constexpr int RoundCount = 64; +constexpr int PageCount = 4; +constexpr int WorkerCount = 4; +constexpr int JobWaitTimeoutMs = 15000; + +/// The single place where an asynchronous result becomes visible state. +/// +/// Product consumers - the findings model, the tile presenter, the evidence +/// cache - all follow the same rule, so the stress test models them with one +/// type: publish() moves the fence, apply() admits a result only when the +/// result's complete revision still equals the fence, and lookup() refuses to +/// hand back an entry the fence has since superseded. +/// +/// One mutex covers the fence and the retained entries together. Without that, +/// a mutation on the owning thread and a result arriving on a worker could +/// interleave into an accepted stale entry - exactly the defect the revision +/// authority exists to make impossible. +class RevisionGate +{ +public: + explicit RevisionGate(pdf::PDFRevisionIdentity revision) : + m_current(std::move(revision)) + { + } + + /// Moves the fence. Everything computed against the previous revision is + /// dropped rather than reconciled, so nothing survives the boundary. + void publish(pdf::PDFRevisionIdentity revision) + { + std::lock_guard lock(m_mutex); + m_current = std::move(revision); + m_entries.clear(); + } + + /// Admits a result computed against `revision`. Returns whether it became + /// visible state. + bool apply(const QString& kind, int page, const pdf::PDFRevisionIdentity& revision) + { + std::lock_guard lock(m_mutex); + if (!(revision == m_current)) + { + ++m_rejected; + return false; + } + + m_entries.insert(entryKey(kind, page), revision); + ++m_applied; + return true; + } + + /// Cache read. A hit that does not carry the current revision is a defect, + /// not a miss to be reconciled - it is recorded and the entry is dropped. + std::optional lookup(const QString& kind, int page) + { + std::lock_guard lock(m_mutex); + const auto it = m_entries.constFind(entryKey(kind, page)); + if (it == m_entries.constEnd()) + { + return std::nullopt; + } + + if (!(it.value() == m_current)) + { + recordViolationLocked(QStringLiteral("cache returned %1 for %2, current is %3") + .arg(it.value().toString(), entryKey(kind, page), m_current.toString())); + m_entries.remove(entryKey(kind, page)); + return std::nullopt; + } + + return it.value(); + } + + /// Audits every retained entry against the fence. + void auditRetainedEntries() + { + std::lock_guard lock(m_mutex); + for (auto it = m_entries.constBegin(); it != m_entries.constEnd(); ++it) + { + if (!(it.value() == m_current)) + { + recordViolationLocked(QStringLiteral("stale entry %1 retained at revision %2, current is %3") + .arg(it.key(), it.value().toString(), m_current.toString())); + } + } + } + + void recordViolation(QString description) + { + std::lock_guard lock(m_mutex); + recordViolationLocked(std::move(description)); + } + + QStringList violations() const + { + std::lock_guard lock(m_mutex); + return m_violations; + } + + int appliedCount() const + { + std::lock_guard lock(m_mutex); + return m_applied; + } + +private: + static QString entryKey(const QString& kind, int page) + { + return QStringLiteral("%1/%2").arg(kind).arg(page); + } + + void recordViolationLocked(QString description) + { + // Bounded: a broken fence would otherwise produce thousands of lines + // and bury the first, most diagnosable failure. + if (m_violations.size() < 16) + { + m_violations.append(std::move(description)); + } + } + + mutable std::mutex m_mutex; + pdf::PDFRevisionIdentity m_current; + QHash m_entries; + QStringList m_violations; + int m_applied = 0; + int m_rejected = 0; +}; + +pdf::PDFDocument buildDocument() +{ + pdf::PDFDocumentBuilder builder; + for (int page = 0; page < PageCount; ++page) + { + builder.appendPage(QRectF(0, 0, 100, 100)); + } + return builder.build(); +} + +pdf::PDFArtifactIdentity buildArtifact(const QString& storageToken) +{ + pdf::PDFArtifactIdentity artifact; + artifact.sha256 = QString(64, QLatin1Char('b')); + artifact.size = 4096; + artifact.logicalName = QStringLiteral("revision-stress.pdf"); + artifact.storageToken = storageToken; + return artifact; +} + +struct JobKindSpec +{ + pdf::PDFJobKind kind; + pdf::PDFJobPriority priority; + const char* consumer; + const char* operationId; +}; + +constexpr JobKindSpec JobKinds[] = { + { pdf::PDFJobKind::Rendering, pdf::PDFJobPriority::VisiblePage, "tile", "render" }, + { pdf::PDFJobKind::Preflight, pdf::PDFJobPriority::Operator, "finding", "preflight" }, + { pdf::PDFJobKind::Thumbnail, pdf::PDFJobPriority::NearViewport, "tile", "thumbnail" }, + { pdf::PDFJobKind::Other, pdf::PDFJobPriority::Operator, "finding", "repair-plan" } +}; + +constexpr int JobKindCount = int(std::size(JobKinds)); + +} // namespace + +class RevisionStressTest : public QObject +{ + Q_OBJECT + +private slots: + void concurrentJobsNeverPublishStaleResults(); + void cancellationIsDeterministicAndPublishesNothing(); + void sessionCachesNeverServeSupersededRevisions(); +}; + +void RevisionStressTest::concurrentJobsNeverPublishStaleResults() +{ + pdf::PDFDocument document = buildDocument(); + pdf::PDFDocumentContext context(&document); + pdf::PDFJobScheduler scheduler(WorkerCount); + + const QString documentKey = context.getDocumentIdentity().documentId; + const pdf::PDFArtifactIdentity artifact = buildArtifact(documentKey); + + RevisionGate gate(context.getRevision()); + scheduler.setCurrentRevision(documentKey, context.getRevision().toString()); + + QStringList jobIds; + + for (int round = 0; round < RoundCount; ++round) + { + for (int index = 0; index < JobKindCount; ++index) + { + const JobKindSpec& kindSpec = JobKinds[index]; + const int page = (round + index) % PageCount; + + // The revision is captured with the submission, exactly as a product + // producer captures it, and travels with the result. + const pdf::PDFRevisionIdentity submittedRevision = context.getRevision(); + + pdf::PDFJobSpec spec; + spec.kind = kindSpec.kind; + spec.priority = kindSpec.priority; + spec.artifact = artifact; + spec.documentKey = documentKey; + spec.documentRevision = submittedRevision.toString(); + spec.operationId = QString::fromLatin1(kindSpec.operationId); + spec.staleResultPolicy = pdf::PDFJobStaleResultPolicy::Discard; + + const QString consumer = QString::fromLatin1(kindSpec.consumer); + jobIds.append(scheduler.submit(spec, + [&gate, submittedRevision, consumer, page, index](pdf::PDFJobContext& jobContext) + { + // Simulated work of varying length, so producers finish on + // both sides of the mutations happening on the owning thread. + for (int step = 0; step < (index % 3) + 1; ++step) + { + if (jobContext.isCancellationRequested()) + { + return; + } + std::this_thread::yield(); + } + + gate.apply(consumer, page, submittedRevision); + })); + } + + // Mutate while earlier rounds are still running. The mutation and the + // fence publication happen on the owning thread; producers never touch + // the context. + if (round % 2 == 0) + { + context.markModified(pdf::PDFModifiedDocument::PageContents); + const pdf::PDFRevisionIdentity currentRevision = context.getRevision(); + scheduler.setCurrentRevision(documentKey, currentRevision.toString()); + gate.publish(currentRevision); + } + else if (round % 5 == 0) + { + // A profile change fences profile-dependent entries without + // pretending the PDF bytes changed. + context.setEffectiveProfileIdentity(QStringLiteral("profile-%1").arg(round)); + const pdf::PDFRevisionIdentity currentRevision = context.getRevision(); + scheduler.setCurrentRevision(documentKey, currentRevision.toString()); + gate.publish(currentRevision); + } + + // Read back through the cache while producers are still active. + for (int page = 0; page < PageCount; ++page) + { + const std::optional tile = gate.lookup(QStringLiteral("tile"), page); + if (tile.has_value() && !context.isCurrent(tile.value())) + { + gate.recordViolation(QStringLiteral("tile cache served %1 outside the current revision") + .arg(tile.value().toString())); + } + } + } + + for (const QString& jobId : std::as_const(jobIds)) + { + QVERIFY2(scheduler.waitForFinished(jobId, JobWaitTimeoutMs), + qPrintable(QStringLiteral("job %1 did not reach a terminal state").arg(jobId))); + + const pdf::PDFJobSnapshot snapshot = scheduler.snapshot(jobId); + QVERIFY2(snapshot.status == pdf::PDFJobStatus::Succeeded || + snapshot.status == pdf::PDFJobStatus::Stale, + qPrintable(QStringLiteral("job %1 finished as %2") + .arg(jobId, QString::fromLatin1(pdf::getPDFJobStatusName(snapshot.status))))); + QCOMPARE(snapshot.documentKey, documentKey); + } + + gate.auditRetainedEntries(); + QVERIFY2(gate.violations().isEmpty(), qPrintable(gate.violations().join(QStringLiteral("; ")))); + + // A fence that rejected everything would satisfy every assertion above, so + // first pin down the other half of the rule: a result carrying the current + // revision is admitted, and reads it back as current. + const int appliedBeforeCurrentPhase = gate.appliedCount(); + QStringList currentJobIds; + + for (int index = 0; index < JobKindCount; ++index) + { + const JobKindSpec& kindSpec = JobKinds[index]; + const pdf::PDFRevisionIdentity submittedRevision = context.getRevision(); + + pdf::PDFJobSpec spec; + spec.jobId = QStringLiteral("current-%1").arg(index); + spec.kind = kindSpec.kind; + spec.priority = kindSpec.priority; + spec.artifact = artifact; + spec.documentKey = documentKey; + spec.documentRevision = submittedRevision.toString(); + spec.operationId = QString::fromLatin1(kindSpec.operationId); + spec.staleResultPolicy = pdf::PDFJobStaleResultPolicy::Discard; + + const QString consumer = QString::fromLatin1(kindSpec.consumer); + currentJobIds.append(scheduler.submit(spec, + [&gate, submittedRevision, consumer, index](pdf::PDFJobContext&) + { + if (!gate.apply(consumer, index, submittedRevision)) + { + gate.recordViolation(QStringLiteral("current result for %1/%2 was rejected") + .arg(consumer) + .arg(index)); + } + })); + } + + for (const QString& jobId : std::as_const(currentJobIds)) + { + QVERIFY(scheduler.waitForFinished(jobId, JobWaitTimeoutMs)); + QCOMPARE(scheduler.snapshot(jobId).status, pdf::PDFJobStatus::Succeeded); + } + + QCOMPARE(gate.appliedCount(), appliedBeforeCurrentPhase + JobKindCount); + for (int index = 0; index < JobKindCount; ++index) + { + const std::optional entry = + gate.lookup(QString::fromLatin1(JobKinds[index].consumer), index); + QVERIFY(entry.has_value()); + QVERIFY(context.isCurrent(entry.value())); + } + + QVERIFY2(gate.violations().isEmpty(), qPrintable(gate.violations().join(QStringLiteral("; ")))); + + // The churn above cannot guarantee, by timing alone, that a result was ever + // actually superseded mid-flight, and a stress test that silently stops + // exercising the fence is worse than no test. This phase forces it: one + // producer per job kind is held inside its work function, the document is + // mutated underneath all of them, and only then are they released. + const int appliedBeforeSupersession = gate.appliedCount(); + std::atomic_bool releaseProducers = false; + std::atomic_int heldProducers = 0; + std::atomic_int rejectedResults = 0; + QStringList heldJobIds; + + for (int index = 0; index < JobKindCount; ++index) + { + const JobKindSpec& kindSpec = JobKinds[index]; + const pdf::PDFRevisionIdentity submittedRevision = context.getRevision(); + + pdf::PDFJobSpec spec; + spec.jobId = QStringLiteral("superseded-%1").arg(index); + spec.kind = kindSpec.kind; + spec.priority = kindSpec.priority; + spec.artifact = artifact; + spec.documentKey = documentKey; + spec.documentRevision = submittedRevision.toString(); + spec.operationId = QString::fromLatin1(kindSpec.operationId); + spec.staleResultPolicy = pdf::PDFJobStaleResultPolicy::Discard; + + const QString consumer = QString::fromLatin1(kindSpec.consumer); + heldJobIds.append(scheduler.submit(spec, + [&gate, &releaseProducers, &heldProducers, &rejectedResults, submittedRevision, consumer, index](pdf::PDFJobContext&) + { + ++heldProducers; + while (!releaseProducers.load(std::memory_order_acquire)) + { + std::this_thread::yield(); + } + + if (!gate.apply(consumer, index, submittedRevision)) + { + ++rejectedResults; + } + })); + } + + QTRY_VERIFY_WITH_TIMEOUT(heldProducers.load(std::memory_order_acquire) == JobKindCount, 5000); + + const pdf::PDFRevisionIdentity supersededRevision = context.getRevision(); + context.markModified(pdf::PDFModifiedDocument::PageContents); + const pdf::PDFRevisionIdentity currentRevision = context.getRevision(); + QVERIFY(currentRevision.documentRevision > supersededRevision.documentRevision); + QVERIFY(currentRevision.cacheGeneration > supersededRevision.cacheGeneration); + scheduler.setCurrentRevision(documentKey, currentRevision.toString()); + gate.publish(currentRevision); + releaseProducers.store(true, std::memory_order_release); + + for (const QString& jobId : std::as_const(heldJobIds)) + { + QVERIFY(scheduler.waitForFinished(jobId, JobWaitTimeoutMs)); + + // Rejected twice over: by the consumer's fence check, and by the + // scheduler before the result is reported as a success. + const pdf::PDFJobSnapshot snapshot = scheduler.snapshot(jobId); + QCOMPARE(snapshot.status, pdf::PDFJobStatus::Stale); + QCOMPARE(snapshot.documentRevision, supersededRevision.toString()); + } + + QCOMPARE(rejectedResults.load(std::memory_order_acquire), JobKindCount); + QCOMPARE(gate.appliedCount(), appliedBeforeSupersession); + + gate.auditRetainedEntries(); + QVERIFY2(gate.violations().isEmpty(), qPrintable(gate.violations().join(QStringLiteral("; ")))); +} + +void RevisionStressTest::cancellationIsDeterministicAndPublishesNothing() +{ + pdf::PDFDocument document = buildDocument(); + pdf::PDFDocumentContext context(&document); + pdf::PDFJobScheduler scheduler(WorkerCount); + + const QString documentKey = context.getDocumentIdentity().documentId; + const pdf::PDFArtifactIdentity artifact = buildArtifact(documentKey); + + RevisionGate gate(context.getRevision()); + scheduler.setCurrentRevision(documentKey, context.getRevision().toString()); + + std::atomic_int startedJobs = 0; + std::atomic_int cancellationsObserved = 0; + QStringList jobIds; + + for (int index = 0; index < JobKindCount; ++index) + { + const JobKindSpec& kindSpec = JobKinds[index]; + const pdf::PDFRevisionIdentity submittedRevision = context.getRevision(); + + pdf::PDFJobSpec spec; + spec.jobId = QStringLiteral("cancelled-%1").arg(index); + spec.kind = kindSpec.kind; + spec.priority = kindSpec.priority; + spec.artifact = artifact; + spec.documentKey = documentKey; + spec.documentRevision = submittedRevision.toString(); + spec.operationId = QString::fromLatin1(kindSpec.operationId); + spec.staleResultPolicy = pdf::PDFJobStaleResultPolicy::Discard; + + const QString consumer = QString::fromLatin1(kindSpec.consumer); + jobIds.append(scheduler.submit(spec, + [&gate, &startedJobs, &cancellationsObserved, submittedRevision, consumer, index](pdf::PDFJobContext& jobContext) + { + ++startedJobs; + + // Runs until cancellation is observed, so the outcome + // does not depend on timing: this job never completes + // its work on its own. + while (!jobContext.isCancellationRequested()) + { + std::this_thread::yield(); + } + + // Publication is guarded by the cancellation check, as + // in a real producer. Cancelled work publishes nothing. + if (jobContext.isCancellationRequested()) + { + ++cancellationsObserved; + return; + } + + gate.apply(consumer, index, submittedRevision); + })); + } + + QTRY_VERIFY_WITH_TIMEOUT(startedJobs.load(std::memory_order_acquire) == JobKindCount, 5000); + + for (const QString& jobId : std::as_const(jobIds)) + { + QVERIFY(scheduler.cancel(jobId)); + } + + for (const QString& jobId : std::as_const(jobIds)) + { + QVERIFY(scheduler.waitForFinished(jobId, JobWaitTimeoutMs)); + + const pdf::PDFJobSnapshot snapshot = scheduler.snapshot(jobId); + QCOMPARE(snapshot.status, pdf::PDFJobStatus::Cancelled); + QVERIFY(snapshot.cancellationLatencyMs >= 0); + + // Cancellation is terminal: a second request finds nothing to cancel and + // the status does not drift afterwards. + QVERIFY(!scheduler.cancel(jobId)); + QCOMPARE(scheduler.snapshot(jobId).status, pdf::PDFJobStatus::Cancelled); + } + + // The jobs above return only after cancellation is observed and publish + // nothing on that path, so no cancelled producer ever became visible state. + QCOMPARE(cancellationsObserved.load(std::memory_order_acquire), JobKindCount); + QCOMPARE(gate.appliedCount(), 0); + gate.auditRetainedEntries(); + QVERIFY2(gate.violations().isEmpty(), qPrintable(gate.violations().join(QStringLiteral("; ")))); +} + +void RevisionStressTest::sessionCachesNeverServeSupersededRevisions() +{ + pdf::PDFDocument document = buildDocument(); + pdf::PDFDocumentContext context(&document); + + pdf::PDFDocumentSession* session = context.getSession(); + QVERIFY(session != nullptr); + QVERIFY(session->isValid()); + + for (int round = 0; round < 16; ++round) + { + const pdf::PDFRevisionIdentity beforeRevision = context.getRevision(); + QVERIFY(session->getRevision() == beforeRevision); + + const pdf::PDFPrecompiledPage* compiled = session->compilePage(size_t(round % PageCount)); + QVERIFY(compiled != nullptr); + QVERIFY(session->compiledCacheBytes() > 0); + + // A document mutation and a profile change are both invalidation + // boundaries: nothing compiled before them may be served afterwards. + if (round % 2 == 0) + { + context.markModified(pdf::PDFModifiedDocument::PageContents); + } + else + { + context.setEffectiveProfileIdentity(QStringLiteral("profile-%1").arg(round)); + } + + const pdf::PDFRevisionIdentity afterRevision = context.getRevision(); + QVERIFY(!(afterRevision == beforeRevision)); + QVERIFY(!context.isCurrent(beforeRevision)); + QVERIFY(context.isCurrent(afterRevision)); + + // The session follows the context, and its caches were dropped rather + // than reconciled against the new revision. + QVERIFY(session->getRevision() == afterRevision); + QVERIFY(!session->isCurrent(beforeRevision)); + QCOMPARE(session->compiledCacheBytes(), qsizetype(0)); + + const pdf::PDFPrecompiledPage* recompiled = session->compilePage(size_t(round % PageCount)); + QVERIFY(recompiled != nullptr); + QCOMPARE(session->compilePage(size_t(round % PageCount)), recompiled); + } +} + +QTEST_GUILESS_MAIN(RevisionStressTest) + +#include "tst_revisionstresstest.moc" diff --git a/UnitTests/tst_workloadenvelopetest.cpp b/UnitTests/tst_workloadenvelopetest.cpp index 796c2a35f..721a0649f 100644 --- a/UnitTests/tst_workloadenvelopetest.cpp +++ b/UnitTests/tst_workloadenvelopetest.cpp @@ -135,6 +135,7 @@ void WorkloadEnvelopeTest::pageHeavyEnvelopeRecordsIdentity() QVERIFY(json.value(QStringLiteral("identity")).toObject().contains(QStringLiteral("os"))); QVERIFY(json.value(QStringLiteral("identity")).toObject().contains(QStringLiteral("qt"))); QVERIFY(!json.value(QStringLiteral("identity")).toObject().value(QStringLiteral("fixture_digest")).toString().isEmpty()); + QVERIFY(json.contains(QStringLiteral("process_commit_high_water_bytes"))); QVERIFY(json.value(QStringLiteral("prefetch_shed")).toBool()); QVERIFY(json.value(QStringLiteral("interaction_slot_held")).toBool()); QVERIFY(json.value(QStringLiteral("resources")).toObject().contains(QStringLiteral("pools"))); diff --git a/WixInstaller/Product.wxs.in b/WixInstaller/Product.wxs.in index fbb1b7f54..ae88f8a48 100644 --- a/WixInstaller/Product.wxs.in +++ b/WixInstaller/Product.wxs.in @@ -254,6 +254,9 @@ ${LOOP_WIX_QT_STYLES_COMPONENT} + + + diff --git a/agent-policy.json b/agent-policy.json index 2824c842c..a1c41a4f1 100644 --- a/agent-policy.json +++ b/agent-policy.json @@ -7,10 +7,12 @@ "branches": { "default": "stable", "integration": "dev", + "qualification": "unstable", "release": "stable", "topic_source": "dev", "topic_branch_patterns": ["gh-*", "feature/*", "fix/*", "chore/*", "docs/*"], - "protected": ["dev", "stable"] + "promotion_chain": ["dev", "unstable", "stable"], + "protected": ["unstable", "stable"] }, "autonomy": { "allowed": [ @@ -54,15 +56,18 @@ "paths": [ "LoopLibCore/**", "UnitTests/tst_bleedfixuptest.cpp", + "UnitTests/tst_budgetcorpustest.cpp", "UnitTests/tst_documentsessiontest.cpp", "UnitTests/tst_incrementalsavetest.cpp", - "UnitTests/tst_overprinttest.cpp" + "UnitTests/tst_overprinttest.cpp", + "UnitTests/tst_revisionstresstest.cpp" ], "targets": ["LoopLibCore"], "tests": [ "UnitTests", "UnitTestsBenchmarkIdentity", "UnitTestsBleedFixup", + "UnitTestsBudgetCorpus", "UnitTestsBudgetExhaustion", "UnitTestsConversionOracle", "UnitTestsDocumentSession", @@ -82,6 +87,7 @@ "UnitTestsPreflightProfileResolver", "UnitTestsPreflightVerdict", "UnitTestsProcessingBudget", + "UnitTestsRevisionStress", "UnitTestsSchemaEvolution", "UnitTestsStandardOracle", "UnitTestsWorkloadEnvelope" diff --git a/changes/cc-gh146-interaction-trace-corpus.md b/changes/cc-gh146-interaction-trace-corpus.md new file mode 100644 index 000000000..4e93c5f33 --- /dev/null +++ b/changes/cc-gh146-interaction-trace-corpus.md @@ -0,0 +1,4 @@ +Category: internal +Audience: developers +Breaking-Change: no +Summary: Add the interaction-performance trace corpus for issue #146: a scenario schema, a report schema, nine seed scenarios with a digest manifest, and a CI checker that validates the corpus with no build. A failing run must name the contract it broke and the phase responsible, missing telemetry must be reported as unavailable rather than as zero, and a scenario whose harness support has not landed is marked blocked so the coverage check stays strict for the rest. Document the two lanes and the phase vocabulary in the interaction contract. diff --git a/changes/cc-nice-noether-u5ie9s.md b/changes/cc-nice-noether-u5ie9s.md new file mode 100644 index 000000000..d63b7c5ee --- /dev/null +++ b/changes/cc-nice-noether-u5ie9s.md @@ -0,0 +1,19 @@ +Category: added +Audience: developers +Breaking-Change: no +Summary: Add gh-243's resource-exhaustion corpus: seven small (a few KB), deterministic, synthetic +adversarial PDF fixtures generated by scripts/resource_envelope/budget_exhaustion_corpus.py, one +per pdf::PDFProcessingBudget dimension named in the issue (a decompression bomb, cumulative +decoded bytes across many streams, a deep Form-XObject Do chain, an operator-heavy content stream, +an oversized declared page extent probed by the thin-parts check, a deeply nested object-array +literal, and a pathological indirect-object count). New UnitTestsBudgetCorpus reads each fixture +through PDFDocumentReader/PreflightEngine the way a real upload would be handled and asserts the +run terminates within a bounded time and fails closed with the exact exceeded budget attributed: +for the five checked through an already-parsed document, the structured +checks[].budget.{kind,pool,limit,attempted} fields and an Incomplete reducePreflightVerdict(); +for the two that are PDF-object-graph properties tripped inside PDFDocumentReader itself before a +document exists, a failed read whose error message names the same kind, limit, and attempted +value. This is the adversarial complement to the existing tst_budgetexhaustiontest.cpp +(direct-API coverage of every budget kind) and to gh-64's memory-safety fuzz corpus: not "does it +crash" but "does it fail closed with an attributable reason." Document the corpus in +docs/RESOURCE_BUDGETS.md. diff --git a/changes/cdx-0.2.0-p5session7.md b/changes/cdx-0.2.0-p5session7.md new file mode 100644 index 000000000..1876ef6a5 --- /dev/null +++ b/changes/cdx-0.2.0-p5session7.md @@ -0,0 +1,4 @@ +Category: internal +Audience: developers +Breaking-Change: no +Summary: Extend the Phase 5 residue sweep to generated policy adapters and normalize current Quick/Core architecture guidance. diff --git a/changes/cdx-ci-packaging-repair.md b/changes/cdx-ci-packaging-repair.md new file mode 100644 index 000000000..65397b7c3 --- /dev/null +++ b/changes/cdx-ci-packaging-repair.md @@ -0,0 +1,4 @@ +Category: fixed +Audience: maintainers +Breaking-Change: no +Summary: Create the vcpkg cache directory on Linux and include the staged Qt config in the Windows MSI. diff --git a/changes/cdx-identity-fix-followup.md b/changes/cdx-identity-fix-followup.md new file mode 100644 index 000000000..a64c86d08 --- /dev/null +++ b/changes/cdx-identity-fix-followup.md @@ -0,0 +1,4 @@ +Category: fixed +Audience: users +Breaking-Change: no +Summary: Validate resource-envelope evidence identity per run, ensuring PdfTool commit and fixture digest match the current candidate and input. \ No newline at end of file diff --git a/changes/cdx-issue-242-qualification-matrix.md b/changes/cdx-issue-242-qualification-matrix.md new file mode 100644 index 000000000..ded4e935f --- /dev/null +++ b/changes/cdx-issue-242-qualification-matrix.md @@ -0,0 +1,4 @@ +Category: internal +Audience: developers +Breaking-Change: no +Summary: Add a fixture matrix that runs each PDF in a fresh process, validates the digest and size from the manifest, compares RSS and elapsed time to a baseline, and checks cancel behavior. Rasterizers are pinned to 8 so results stay comparable. diff --git a/changes/cdx-package-boundary-linux-compile.md b/changes/cdx-package-boundary-linux-compile.md new file mode 100644 index 000000000..7c97cf1f9 --- /dev/null +++ b/changes/cdx-package-boundary-linux-compile.md @@ -0,0 +1,4 @@ +Category: fixed +Audience: developers +Breaking-Change: no +Summary: Fix the Quick document model's form-presence check for the current PDFObject API so Linux package qualification builds successfully. diff --git a/changes/cdx-quick-pdf4qt-core-parity.md b/changes/cdx-quick-pdf4qt-core-parity.md new file mode 100644 index 000000000..a8a840463 --- /dev/null +++ b/changes/cdx-quick-pdf4qt-core-parity.md @@ -0,0 +1,4 @@ +Category: added +Audience: developers, users +Breaking-Change: no +Summary: Add the first Quick parity slice: page and outline models, search, properties and capability handling, plus workspace navigation and layout controls. Includes focused tests. Other workflows remain declared but not yet implemented. diff --git a/changes/cdx-session-07-package-boundary-fix.md b/changes/cdx-session-07-package-boundary-fix.md new file mode 100644 index 000000000..fa7477baf --- /dev/null +++ b/changes/cdx-session-07-package-boundary-fix.md @@ -0,0 +1,4 @@ +Category: internal +Audience: developers +Breaking-Change: no +Summary: Align Linux package deployment with the pinned linuxdeployqt glibc floor and make the Windows staged Qt runtime self-describing for clean installed-artifact smoke. diff --git a/changes/claude-agent-fast-build-promotion-9ljtaq.md b/changes/claude-agent-fast-build-promotion-9ljtaq.md new file mode 100644 index 000000000..d05e6b37b --- /dev/null +++ b/changes/claude-agent-fast-build-promotion-9ljtaq.md @@ -0,0 +1,6 @@ +# Unstable qualification branch policy + +Category: internal +Audience: developers and release operators +Breaking-Change: no +Summary: Document the four-stage promotion chain (topic branch → dev → unstable → stable) and move the fast integration gate protections from dev to unstable. diff --git a/changes/cursor-package-boundary-fixes-ffa1.md b/changes/cursor-package-boundary-fixes-ffa1.md new file mode 100644 index 000000000..d4d9d3f2c --- /dev/null +++ b/changes/cursor-package-boundary-fixes-ffa1.md @@ -0,0 +1,4 @@ +Category: fixed +Audience: maintainers +Breaking-Change: no +Summary: Stage Loop.Quick from the LoopEditor build directory where qt_add_qml_module emits it, unblocking Linux_AppImage and Windows_MSI package workflows. diff --git a/changes/cursor-pr-490-promotion-fix-23e1.md b/changes/cursor-pr-490-promotion-fix-23e1.md new file mode 100644 index 000000000..c8836eed3 --- /dev/null +++ b/changes/cursor-pr-490-promotion-fix-23e1.md @@ -0,0 +1,4 @@ +Category: internal +Audience: maintainers +Breaking-Change: no +Summary: Merge unstable into dev for PR 490, resolve legacy-to-Loop rename conflicts, refresh Phase 5 widgets evidence and interaction-trace digests, and fix policy/source_integrity regressions blocking the 0.2.1 promotion. diff --git a/changes/cursor-quickdocumentmodel-form-fix-06ea.md b/changes/cursor-quickdocumentmodel-form-fix-06ea.md new file mode 100644 index 000000000..678f4af77 --- /dev/null +++ b/changes/cursor-quickdocumentmodel-form-fix-06ea.md @@ -0,0 +1,4 @@ +Category: fixed +Audience: developers +Breaking-Change: no +Summary: Use PDFForm parsing to detect interactive forms in QuickDocumentModel instead of calling a nonexistent PDFObject::isValid(). diff --git a/changes/cursor-session-07-closeout-06ea.md b/changes/cursor-session-07-closeout-06ea.md new file mode 100644 index 000000000..9a3c3468f --- /dev/null +++ b/changes/cursor-session-07-closeout-06ea.md @@ -0,0 +1,4 @@ +Category: fixed +Audience: maintainers +Breaking-Change: no +Summary: Stage the built Loop.Quick QML module in Linux and Windows package trees and drop unbundlable Qt SQL drivers before linuxdeployqt so Session 07 package-boundary smoke can pass with scrubbed Qt paths. diff --git a/changes/cursor-unstable-to-stable-23e1.md b/changes/cursor-unstable-to-stable-23e1.md new file mode 100644 index 000000000..0cf9707e8 --- /dev/null +++ b/changes/cursor-unstable-to-stable-23e1.md @@ -0,0 +1,4 @@ +Category: changed +Audience: release operators +Breaking-Change: no +Summary: Promote qualified unstable line (0.2.1 agent-fast gate move and Loop rename integration) into stable for release review. diff --git a/changes/feat-236-revision-stress.md b/changes/feat-236-revision-stress.md new file mode 100644 index 000000000..1cc11b435 --- /dev/null +++ b/changes/feat-236-revision-stress.md @@ -0,0 +1,4 @@ +Category: internal +Audience: maintainers and qualification operators +Breaking-Change: no +Summary: Add the concurrent revision-authority stress scenario for issue #236, covering simultaneous render, preflight, thumbnail, and repair-plan jobs across document mutations, deterministic cancellation, and revision-keyed cache reads. diff --git a/docs/0.2.0-closeout-matrix.md b/docs/0.2.0-closeout-matrix.md index 657fda5f4..45ef82acd 100644 --- a/docs/0.2.0-closeout-matrix.md +++ b/docs/0.2.0-closeout-matrix.md @@ -35,6 +35,7 @@ local implementation evidence alone. | Q-02 | Direct canvas | Direct `QQuickItem`, scene-graph lifecycle, fidelity/color, backends | Implemented P4-S5–S6; CI on branch | | Q-03 | Quick product workflow | Open → detect → pinpoint → inspect → understand-state | `UnitTestsProductOperatorLoop` + focused suite on branch | | Q-04 | Surface disposition | All maintained Widgets targets/forms have observed graph rows and one explicit Phase 5 disposition | **Session 01–03 Issue 10:** generated inventory/disposition; Viewer/LaunchPad `DELETE`; PageMaster/Diff `HEADLESS-REPLACE` onto existing Core/CLI owners; Compare workspace remains OPEN. **Session 04 Issue 13:** AudioBook/Ocr removed from install graph; ABSORB/ADVANCED/BLOCKED plugin rows verified by `scripts/verify-plugin-surface-policies.py`; RedactPlugin remains the sole BLOCKED row | +| Q-05 | Interaction regression traces | Replayable scenario corpus, two lanes, and a report that names the first violated contract and the phase responsible | **Partial** — issue #146. The corpus, both schemas, and `scripts/ci/check_interaction_traces.py` are in place and gated in CI (`--corpus-only`, no build). Nine scenarios are tracked; one is marked `blocked_on: gh-488`. The C++ replay harness (`UnitTestsInteractionTraces`), the report writer, and the desktop/GPU present lane are still open, so no verified latency measurement is recorded for this candidate | | W-01 | No Widgets on installed editor | Installed `LoopEditor` must not link or ship Widgets | **Closed (static + configure)** — `verify-installed-product-graph.py`, `verify-widgets-free-release-profile.py` (static + configure probe) in CI, package smoke scans; E-01 hosted proof still open | | P-01 | Cross-platform/package | Linux/Windows native/software smoke, clean-machine package, QML deployment | **Partial** — smoke scripts enforce Qt6Widgets absence; hosted package proof pending merge SHA | | P-02 | Supply chain/licensing | SBOM, notices, LGPL relink evidence | Open — `docs/quick-runtime-manifest.json` release_gates | diff --git a/docs/ACCESSIBILITY_BASELINE.md b/docs/ACCESSIBILITY_BASELINE.md index 24cf01521..7ac4a3f5c 100644 --- a/docs/ACCESSIBILITY_BASELINE.md +++ b/docs/ACCESSIBILITY_BASELINE.md @@ -39,9 +39,9 @@ remains an application-level follow-up under the GUI/E2E harness issue. ADR-007 adopts Qt Quick Controls as the 1.2 shell foundation. It extends this baseline; it does not create a second accessibility standard. Quick components -must expose the same meaningful name, description, role, state, visible focus, -keyboard reachability, contrast, status text, and DPI-aware sizing expected of -Widgets components. +must expose meaningful names, descriptions, roles, states, visible focus, +keyboard reachability, contrast, status text, and DPI-aware sizing under the +same application accessibility baseline. Every Quick `Dialog`, `Menu`, and `Popup` must have a keyboard/focus test that covers opening, traversal, typeahead where applicable, Escape dismissal, diff --git a/docs/BRANCH_POLICY.md b/docs/BRANCH_POLICY.md index bf0bf5477..969aa113b 100644 --- a/docs/BRANCH_POLICY.md +++ b/docs/BRANCH_POLICY.md @@ -1,24 +1,26 @@ # Loop branch policy `stable` is the release line and the repository default branch. `dev` is the -integration line. Short-lived topic branches are created from `dev` and merge -back into `dev`; releases promote reviewed commits from `dev` into `stable`. +first integration line. `unstable` is the qualification line. Short-lived topic +branches are created from `dev` and merge back into `dev`. Reviewed commits +promote along `dev` → `unstable` → `stable`. The full build and CodeQL workflows run for release qualification. `stable` is the protected release branch and requires the `release_ok` GitHub Actions status before merging. That check is produced by the dedicated Release Gate workflow, which always reports: failed, cancelled, skipped, and missing -dependencies reduce to an explicit terminal failure. `dev` is also protected -and requires the fast `agent-fast / build` status before merging. The fast gate +dependencies reduce to an explicit terminal failure. `unstable` is protected +and requires the fast `agent-fast / build` status before merging. That gate checks source integrity, contracts, affected-target compilation, focused tests, and the required PR changelog; expensive cross-platform/package qualification -remains on the release-candidate path. Direct pushes and force-pushes are -disabled by the corresponding GitHub branch rules. +remains on the release-candidate path. `dev` is an integration branch without +branch-rule status requirements; direct pushes and force-pushes are disabled on +the protected branches by the corresponding GitHub branch rules. The Release Gate workflow listens for `pull_request` targeting `stable` and for `merge_group` so an optional merge queue cannot wait on a check that never -runs. It has no path filters. Integration PRs targeting `dev` run `ci.yml` and -must pass `agent-fast / build`. +runs. It has no path filters. Integration PRs targeting `dev` or `unstable` run +`ci.yml`. Merges into `unstable` must pass `agent-fast / build`. The declarations below are intentionally machine-readable by `scripts/ci/check_branch_policy.py`. That check runs in CI, so a workflow @@ -26,8 +28,9 @@ trigger edited away from this policy fails before the build can be cited as release evidence. Pass `--live` to also compare these declarations with GitHub branch protection when a token can read it. -- CI branches: `dev`, `stable` -- Protected branches: `dev`, `stable` +- CI branches: `dev`, `unstable`, `stable` +- Protected branches: `unstable`, `stable` +- Promotion chain: `dev`, `unstable`, `stable` - Required check: `release_ok` - Required check app: GitHub Actions - Required integration check: `agent-fast / build` @@ -35,12 +38,12 @@ branch protection when a token can read it. - Release gate events: `pull_request`, `merge_group` - Release gate pull_request branches: `stable` - Integration workflow: `.github/workflows/ci.yml` -- Integration pull_request branches: `dev` +- Integration pull_request branches: `dev`, `unstable` `master` is not part of the Loop branch policy. It is retained only in older historical documents or upstream references; new workflow triggers must not target it. -Ensure `stable` requires `release_ok` and `dev` requires `agent-fast / build`, +Ensure `stable` requires `release_ok` and `unstable` requires `agent-fast / build`, both bound to the GitHub Actions app (id 15368). The live policy check verifies -both protections. +both protections and rejects status-check requirements on `dev`. diff --git a/docs/CI.md b/docs/CI.md index 99ab0abd7..c4972b24e 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -1,24 +1,25 @@ # CI and diagnostic artifacts -Pull requests to `dev` run the Linux `agent-fast / build` workflow as the -required integration gate. It classifies the diff, runs source and contract -checks, compiles affected targets, and runs focused tests. Pull requests -require one structured changelog fragment under `changes/` named after the -head branch. Subsequent `dev` pushes skip that PR-only check so a merged -topic fragment is not rejected for not being `changes/dev.md`. Stacked topic -branches may carry their parent fragments, but every added fragment is -validated. Format and clang-tidy run on added, modified, renamed, or copied -C/C++ files only; deleted paths still classify modules. These are the fast -checks for -the shared integration baseline. The full Linux and Windows build-and-test -jobs run for release qualification. These are the two platforms Loop V1 -supports; **macOS** CI is a **post-V1** track under +Pull requests to `dev` or `unstable` run the Linux `agent-fast / build` workflow. +Merges into `unstable` require that check. Topic PRs into `dev` still run the +workflow for signal, but `dev` no longer carries branch-rule status requirements. +The workflow classifies the diff, runs source and contract checks, compiles +affected targets, and runs focused tests. Pull requests require one structured +changelog fragment under `changes/` named after the head branch. Subsequent +integration-branch pushes skip that PR-only check so a merged topic fragment is +not rejected for not being `changes/dev.md`. Stacked topic branches may carry +their parent fragments, but every added fragment is validated. Format and +clang-tidy run on added, modified, renamed, or copied C/C++ files only; deleted +paths still classify modules. These are the fast checks for the shared +integration baseline. The full Linux and Windows build-and-test jobs run for +release qualification. These are the two platforms Loop V1 supports; **macOS** +CI is a **post-V1** track under [MIC-336](https://linear.app/mbx2/issue/MIC-336) / [docs/PLATFORM_SUPPORT.md](PLATFORM_SUPPORT.md). Packaging artifacts are produced only for `stable` pushes and manual workflow runs. The standalone `Documentation truth` workflow runs for the policy branches -`dev` and `stable` pull requests and pushes. It checks every ADR's verification +`dev`, `unstable`, and `stable` pull requests and pushes. It checks every ADR's verification header and fails when [`docs/generated/architecture-catalog.json`](generated/architecture-catalog.json) is stale. Product versioning is SemVer 2.0 (`0.2.0-alpha`); CI also runs @@ -30,7 +31,7 @@ runs and fails when any required dependency failed, was cancelled, was skipped, or did not report. Requiring the platform-specific jobs directly would create multiple checks for the same gate. The release-gate workflow runs for every pull request targeting `stable` and for `merge_group` events; -it has no path filters. `dev` requires `agent-fast / build` for merging; +it has no path filters. `unstable` requires `agent-fast / build` for merging; `stable` requires `release_ok`. Hosted fuzzing (`.github/workflows/fuzz.yml`) validates the manifested diff --git a/docs/INTERACTION_CONTRACT.md b/docs/INTERACTION_CONTRACT.md index e6d9a6f67..9fdd7d65f 100644 --- a/docs/INTERACTION_CONTRACT.md +++ b/docs/INTERACTION_CONTRACT.md @@ -190,6 +190,87 @@ feeds it back in order, leaving the controller in the state the original session the same viewport, hit-test sources and document state. Recording is suppressed during replay, so replaying into a recording controller does not append the trace to itself. +## Regression traces + +Issue #146. The corpus lives in `UnitTests/testdata/interaction-traces/`, one +JSON scenario per file plus a `manifest.json` of ids and digests. Schemas: +[interaction-scenario.schema.json](schemas/interaction-scenario.schema.json) and +[interaction-trace-report.schema.json](schemas/interaction-trace-report.schema.json). +`scripts/ci/check_interaction_traces.py --corpus-only` validates the corpus +without a build, so a malformed scenario fails in seconds rather than after a +compile. + +A scenario **embeds** an `InteractionTrace` under `trace`; it does not extend +one. `InteractionTrace` may not carry geometry or target identity — that is the +privacy rule above, and a test enforces it — while a scenario must declare both +to state its fixture and its expected selection. Embedding keeps the shipping +type unchanged and lets a recorded field trace drop in as the `trace` member. A +scenario that would otherwise be hundreds of near-identical records uses +`input_script` instead, and a dense page declares `generated_targets` as a grid: +a corpus nobody can read in review is a corpus nobody checks. + +### Two lanes + +| | Deterministic | Present | +| --- | --- | --- | +| Target | `UnitTestsInteractionTraces` | `UnitTestsInteractionTracesPresent` | +| Clock | `ManualClock`, set from each `InputStamp` | `SteadyMonotonicClock` | +| Budgets | strict, from the scenario | scenario budget × `variance_band_multiplier` | +| Gating | yes | no | + +The deterministic lane reads no real clock. Stage time comes from the +scenario's `cost_model` multiplied by real run products — index candidates, +overlay primitives, cache misses, admitted surfaces — so the same scenario +produces byte-identical output on every machine. + +The cost this buys is worth stating: `StageTimer` measures zero under a manual +clock, so a regression that is purely slower code is invisible in this lane *as +elapsed time*. What catches it is the counts the cost model multiplies, since a +regression that costs time almost always costs one of those. Real elapsed time +lives in the present lane, where a shared CI runner's variance is absorbed by a +band rather than pretended away. + +A present run reports `verified`, `static-only`, or `infrastructure-blocked`. +Only `verified` participates in the band assertion. A lane that cannot measure +presentation reports `available: false` with +`interaction-trace/present-timing-unavailable` and never a zero percentile — +the same rule [RESOURCE_BUDGETS.md](RESOURCE_BUDGETS.md) applies to every other +budget, and the reason a headless run may not be recorded as a desktop result. + +### What a failure says + +A failed run names one contract and one phase (issue #146 AC7). Contracts are +evaluated in a fixed order, so "first violated" is a documented constant rather +than whichever key the JSON happened to yield first: + +`input-acknowledged` → `frame-balance` → `telemetry-available` → +`p95-input-to-frame` → `p95-frame-time` → `slow-frame-budget` → +`dropped-frames` → `stale-result-safety` → `final-state`. + +The phase is derived from the slow-frame attribution, translating trace stages +into the vocabulary the issue asks a reader to act on: + +| `TraceStage` | Phase | +| --- | --- | +| `Interaction` | `input` | +| `HitTest` | `hit-test` | +| `PageSurface` | `page-cache` | +| `Overlay` | `overlay` | +| `External` | `composition` | +| `Unknown` | `async-overlap` when a job overlapped a slow frame, else `unknown` | + +`Unknown` is the interesting row. A frame slowed by something no stage measured +must not have a cause invented for it, but it is not nothing either: if an +expensive job was in flight across it, the overlap is the finding. + +### Scenarios ahead of the harness + +A manifest entry may carry `blocked_on` with a `blocked_reason`. Such a +scenario is validated as data but is not required to produce a run, which is +what lets the coverage check stay strict for everything else — a scenario that +silently stops running is otherwise indistinguishable from one that was never +wired up. + ## Not in this session - The developer-facing trace overlay and GPU/present timing from issue #140. Neither can exist diff --git a/docs/LOOP_SHELL_CONTRACT.md b/docs/LOOP_SHELL_CONTRACT.md index 05d9afc7e..81a27bf01 100644 --- a/docs/LOOP_SHELL_CONTRACT.md +++ b/docs/LOOP_SHELL_CONTRACT.md @@ -3,7 +3,7 @@ This is the non-visual foundation for issue #193. The 0.1.1 release gate is complete, but product GUI work remains gated by the S21 canvas and S22 Quick admission contracts. This document therefore defines the state, routing, and -verification contract without changing the existing Widgets shell. The +verification contract without changing the existing shell. The repository may contain the qualification-only Quick smoke harness; it is not product UI or a shipped Qt Quick surface. @@ -16,7 +16,7 @@ Editor action inventory is recorded in [`loop-shell-actions.json`](loop-shell-ac `LoopEditor` is the installed interactive Loop shell on the P4-S7 navigable product root: a packaged `Loop.Quick` `ApplicationWindow` that opens, closes, reopens, and navigates a PDF through the host-neutral Interaction/Canvas stack. -The former non-installed Widgets migration target has been retired after its +The former non-installed migration target has been retired after its parity assertions were moved into the Quick-native canvas contract suite. This is a navigable slice, not the Phase 4 operator loop or GUI exit gate. @@ -143,14 +143,14 @@ that is not in the contract is reported as a routing error rather than ignored. against `PDFActionManager::initActions` so the catalog cannot become a second command truth wearing the first one's ID set. -The Quick shell policy is self-authoritative; the retired Widgets form is not a +The Quick shell policy is self-authoritative; the retired migration form is not a runtime or documentation dependency. ## UI foundation gate Issue #178 selects Qt Quick Controls for the application shell. The installed `LoopEditor` product root is now Qt Quick (`gui_status: quick-admitted` in -`loop-shell.json`). The migration-only Widgets comparison target is retired; +`loop-shell.json`). The migration-only comparison target is retired; the preserved parity evidence and Quick-native replacement checks are recorded in `docs/evidence/phase5-widgets-parity-evidence.json`. diff --git a/docs/LOOP_WORKSPACES.md b/docs/LOOP_WORKSPACES.md index bd7ccd52d..7c6b9bddc 100644 --- a/docs/LOOP_WORKSPACES.md +++ b/docs/LOOP_WORKSPACES.md @@ -10,15 +10,15 @@ is deferred to #193 and remains outside the pre-0.1.1 GUI scope. Loop has two product surfaces: - **Loop** — `LoopEditor`, the interactive desktop shell. Opening a PDF is - the Document workspace and includes the inherited Viewer behavior. + the Document workspace and includes the standard document-viewing behavior. - **Loop CLI** — `PdfTool`, the headless and automation surface. Its command names, JSON envelopes, and machine-readable capability discovery remain the automation contract. - `LoopLibCore` and `LoopLibQuick` are maintained implementation libraries, not additional user-facing products. -The former Viewer, PageMaster, Diff, LaunchPad, and editor-plugin artifacts are -deleted and absent from both supported profiles. Their dispositions remain in +Former standalone applications and editor-plugin artifacts are deleted and +absent from both supported profiles. Their dispositions remain in `docs/product-surface.json` so an accidental upstream reintroduction fails verification. @@ -29,10 +29,10 @@ receive a separate Loop desktop entry, AppX application, or product identity. | Workspace | Owns | Drives | Explicitly does not own | | --- | --- | --- | --- | -| Document | Open, view, navigate, save/export, and ordinary PDF interaction | `LoopEditor`, `LoopLibQuick`, shared document/session contracts | A separate Viewer product or a second document model | +| Document | Open, view, navigate, save/export, and ordinary PDF interaction | `LoopEditor`, `LoopLibQuick`, shared document/session contracts | A second interactive document product or a second document model | | Preflight | Run/rerun/cancel inspection, findings, evidence, report export, and stale-result state | Core `PreflightEngine`, `PdfTool preflight`, Quick shell contract | A GUI-only interpretation of the CLI report | | Production Preview | Soft proofing, output preview, separations, and production rendering evidence | `LoopLibCore`, `LoopLibQuick`, shared render/color contracts | Final approval or an alternate PDF-writing pipeline | -| Pages / Production | Multi-document assembly, page geometry, crop, regrouping, bleed, optimization, and export | `PDFPageMasterExport`, ADR-003 stage order, ADR-004 batch manifest | A copied PageMaster engine or a reordered export pipeline | +| Pages / Production | Multi-document assembly, page geometry, crop, regrouping, bleed, optimization, and export | `PDFPageMasterExport`, ADR-003 stage order, ADR-004 batch manifest | A copied page-production engine or a reordered export pipeline | | Inspect | Contextual page, image, object, dimension, color, and evidence inspection | Core inspection APIs and the Quick shell contract | A standalone inspector application | | Fix | Deterministic, bounded corrective operations with preview, approval, output, and revalidation | Core repair operations and `PdfTool repair` | Silent mutation, GUI-only business logic, or implicit approval | | Compare | Proposed PDF comparison and production-proof evidence | Core `PDFDiff` contract if the product boundary is approved | An automatic replacement of the retired comparison product | @@ -41,9 +41,9 @@ The shell issue (#193) may model these as stateful workspaces, but switching workspace must preserve the open document and preflight revision. A workspace is not a new executable and must not own a duplicate Core semantic path. -## PageMaster disposition and capability crosswalk +## Page-production disposition and capability crosswalk -PageMaster is recorded as **CLI-ONLY** and its source is deleted. Its +Page production is recorded as **CLI-ONLY** and its former source is deleted. Its historical UI action inventory maps to the Pages / Production workspace later, while `PDFPageMasterExport` and its ADR-003/ADR-004 contracts remain the single source of truth. The retained capability inventory is assigned a destination or @@ -51,7 +51,7 @@ an explicit compatibility disposition. The following action map is retained as product intent; it does not imply that a standalone executable or UI file is still shipped. -| PageMaster action IDs | Disposition | Destination / contract | +| Page-production action IDs | Disposition | Destination / contract | | --- | --- | --- | | `actionOpenWorkspace`, `actionSaveWorkspace`, `actionAddDocuments`, `actionSaveCheckpoint`, `actionLoadCheckpoint`, `actionClear`, `actionClose`, `actionClearRecent`, `actionClearSearch` | ABSORB | Pages / Production workspace lifecycle, search/filter reset, and ADR-004 checkpoint/manifest behavior | | `actionCloneSelection`, `actionRemoveSelection`, `actionReplaceSelection`, `actionRestoreRemovedItems`, `actionCut`, `actionCopy`, `actionPaste` | ABSORB | Pages / Production document-item editing over the existing page-item model | @@ -66,7 +66,7 @@ still shipped. | `actionUndo`, `actionRedo` | ABSORB | Pages / Production history; must remain scoped to the workspace document model | | `actionGet_Source`, `actionBecomeASponsor`, `actionAbout`, `actionPrepare_Icon_Theme` | KEEP / ADVANCED | Loop Help or developer/compatibility path; not a production capability | -No PageMaster semantic contract is silently retired, but the standalone +No page-production semantic contract is silently retired, but the standalone executable is absent from both profiles. Export order remains the ADR-003 contract: assembly, preflight, page geometry, bleed/content fixups, image optimization, then write, with ADR-004 manifest and @@ -75,8 +75,8 @@ rollback behavior unchanged. ## Compare disposition Compare is **OPEN**, not implicitly absorbed. The Core `PDFDiff` contract is -retained while the Diff executable is absent from both profiles because its -source was already deleted. The owner is `m.berry`; #193 is the follow-up for the shell +retained while the standalone comparison executable is absent from both profiles +because its source was already deleted. The owner is `m.berry`; #193 is the follow-up for the shell boundary and #197 is the release exit gate. No new UI replacement or product commitment is authorized by this document. @@ -92,8 +92,7 @@ must have: - one Linux desktop entry, `io.github.mberrys.Loop-pdf.desktop`, launching `LoopEditor` with `application/pdf` association; - one AppX application, `LoopEditor`, with the same PDF association; -- no Viewer, PageMaster, Diff, LaunchPad, or other retired product desktop/AppX - entry; and +- no retired product desktop/AppX entry; and - only the manifest-declared `LoopEditor`, `PdfTool`, `LoopLibCore`, and `LoopLibQuick` first-party artifacts; deleted compatibility/plugin artifacts are forbidden. diff --git a/docs/PLATFORM_SUPPORT.md b/docs/PLATFORM_SUPPORT.md index 05d032723..6567be22c 100644 --- a/docs/PLATFORM_SUPPORT.md +++ b/docs/PLATFORM_SUPPORT.md @@ -55,8 +55,9 @@ fixed absolute location. ## V1 slim distribution When `LOOP_LOOP_DISTRIBUTION=ON`, prefer Editor + PdfTool + core plugins -(LoopPreflight and required inspection plugins). PageMaster / Diff / Viewer / -LaunchPad may ship in full packages; still build them in CI on both supported OS. +(LoopPreflight and required inspection plugins). Retired standalone product +identities are absent from both supported package profiles and are not built by +the release graph. ## Cross-platform compatibility pass @@ -76,7 +77,7 @@ bundling** and **installer packaging** for modules that are already complete. | Page production export (MIC-307–312) | Yes | ☐ | ☐ | Atomic write + manifest; cancel; case-sensitive FS | | Retired secondary product identities | No | N/A | N/A | Replaced by LoopEditor, PdfTool, and in-app workspaces | | loop-preflight profiles + schemas | Yes | ☐ | ☐ | Installed at documented path; schema version contract | -| UnitTests (operator, corpus, PageMaster) | Yes | ☐ | ☐ | `ctest` green on both CI runners | +| UnitTests (operator, corpus, page production) | Yes | ☐ | ☐ | `ctest` green on both CI runners | | Windows MSI | Session 07 exact-SHA package boundary | ☐ | — | x64 WiX package, dependency evidence, clean VM operator/a11y loop; **V1 ships unsigned** (MIC-342 / MIC-345) | | Linux AppImage | Session 07 exact-SHA package boundary | — | ☐ | x86_64 package, dependency evidence, clean VM operator/a11y loop | | Flatpak / MSIX / portable ZIP | Out of Session 07 scope | — | — | Build or sandbox work may exist, but these formats are not release-gate evidence | @@ -157,7 +158,8 @@ candidate resolved and keep the layout table above synchronized with the VM evid macOS is explicitly **out of scope for V1**. The work below is retained as the entry criteria for adding it in a later release, not as a V1 checklist. -- Apps already set `MACOSX_BUNDLE ON` for Editor, Viewer, PageMaster, Diff, LaunchPad. +- The future application bundle must contain the Quick-based `LoopEditor` only; + retired standalone product identities are not macOS bundle targets. - CMake today treats non-`LOOP_LINUX` like Windows for `LOOP_PLUGINS_DIR` (`pdfplugins`, `CMakeLists.txt:198-201`). That path must be confirmed inside a `.app` bundle or the install rules adjusted. - A `macos` job in `ci.yml` with Qt 6.11.1 + vcpkg, mirroring the Ubuntu/Windows `ctest` set, is the minimum bar before any macOS claim is restored. - Notarization and staple steps belong in a dedicated `macOSInstall.yml` before attaching artifacts to the release draft. This requires an **Apple Developer Program** enrollment, which is not currently held. diff --git a/docs/QUICK_PDF_PARITY.md b/docs/QUICK_PDF_PARITY.md new file mode 100644 index 000000000..34d21082c --- /dev/null +++ b/docs/QUICK_PDF_PARITY.md @@ -0,0 +1,26 @@ +# Quick competitor parity + +The Quick shell owns the interactive PDF surface; `LoopLibCore` remains the +owner of PDF objects, document revisions, and persistence. The first parity +slice is intentionally model-driven: + +- `QuickDocumentModel` exposes immutable page, outline, properties, capability, + lifecycle, attachment presence, optional-content presence, and revision values + to QML. +- `QuickSearchResultModel` admits Core text-search results only when the + captured `PDFRevisionIdentity` is still current. +- `DocumentPane.qml` provides pages, outline, search, next/previous result + navigation, and the existing canvas in one Document workspace. +- Layout, fullscreen, find, and properties use the existing + `CommandCatalog`; there is no QML action registry. + +The following remain deliberately declared or policy-excluded until their +typed bridge and revision-fenced tests land: annotation/form overlays and +editing, attachments and metadata editing, print/export, undo/redo, password +and encryption workflows, sanitization, optimization, signature verification, +OCR, PageMaster, Compare, Redaction, signature creation, and deep inspection. + +Search currently runs through the Core model on the host thread. It is a +functional read-only bridge, but its next hardening step is to submit the same +snapshot computation through `PDFJobScheduler` and admit the value on the +owner thread, matching the renderer and preflight paths. diff --git a/docs/REPO_MAP.md b/docs/REPO_MAP.md index ed531727a..043d6a4f4 100644 --- a/docs/REPO_MAP.md +++ b/docs/REPO_MAP.md @@ -7,7 +7,7 @@ tracking policy. | Role | Repository | Branch | |------|------------|--------| -| Loop canonical repository | [studio-berry/loop](https://github.com/studio-berry/loop) | `stable` (default/release), `dev` (integration) | +| Loop canonical repository | [studio-berry/loop](https://github.com/studio-berry/loop) | `stable` (default/release), `unstable` (qualification), `dev` (integration) | | Upstream PDF engine source | [JakubMelka/PDF4QT](https://github.com/JakubMelka/PDF4QT) | `master` (upstream only) | Loop owns the product decisions, branding, release policy, and downstream @@ -16,10 +16,12 @@ tooling. Do not infer Loop branch policy from upstream's `master` branch. ## Branch policy -- `dev` is the integration branch. +- `dev` is the first integration branch. +- `unstable` is the qualification branch; it carries the fast integration gate + formerly required on `dev`. - `stable` is the release branch and repository default. - Topic branches start from `dev`, stay focused, and merge back to `dev`. -- Releases promote a verified `dev` state to `stable`. +- Reviewed commits promote along `dev` → `unstable` → `stable`. - `master` is not an active Loop branch. The reviewed machine-readable policy is diff --git a/docs/RESOURCE_BUDGETS.md b/docs/RESOURCE_BUDGETS.md index 1a4afb7d4..46be1eb9c 100644 --- a/docs/RESOURCE_BUDGETS.md +++ b/docs/RESOURCE_BUDGETS.md @@ -60,6 +60,45 @@ Synthetic exhaustion fixtures are generated in `UnitTestsBudgetExhaustion` (nested objects, raster size, evidence records, elapsed clock). Do not commit multi-GB binaries. +### Resource-exhaustion corpus (gh-243) + +`UnitTestsBudgetCorpus` is the adversarial complement: instead of calling +`PDFProcessingBudget` directly, it reads small (a few KB) synthetic hostile +PDF files through `PDFDocumentReader`/`PreflightEngine`, the way a real +upload would be handled, and asserts each run terminates within a bounded +time and fails closed with the exact exceeded budget attributed -- never a +hang, an OOM kill, or a clean result over unexamined content. The fixtures +and `UnitTests/testdata/budget_exhaustion/manifest.json` are generated by +`scripts/resource_envelope/budget_exhaustion_corpus.py`, one fixture per +adversarial shape: + +| Fixture | Shape | Budget kind tripped | +|---------|-------|----------------------| +| `decompression-bomb` | FlateDecode stream with an extreme decoded/compressed ratio | `decompression-ratio` | +| `cumulative-decoded-bytes` | Many pages, each with a small unfiltered stream; the sum exceeds the cap | `cumulative-decoded-bytes` | +| `deep-nested-content-streams` | A chain of Form XObjects invoking each other via `Do` | `recursive-content-depth` | +| `long-running-render-work` | A content stream with far more operator/operand tokens than the cap | `render-operations` | +| `raster-probe-pixel-budget` | A large declared page extent probed by the `thin-parts` check | `render-pixels` | +| `deep-recursive-object-graph` | One indirect object holding a deeply nested array literal | `object-depth` | +| `pathological-object-count` | Hundreds of trivial extra indirect objects | `objects-visited` | + +The first five trip their budget while `PreflightEngine` walks an +already-parsed document, so the corpus test asserts the structured +`checks[].budget.{kind,pool,limit,attempted}` fields directly. +`object-depth` and `objects-visited` are structural PDF-object-graph +properties instead: they are budgeted while `PDFDocumentReader` itself walks +every occupied cross-reference entry, before a document exists to run +`PreflightEngine` against, so the reader fails the read outright and the +test recovers the kind and the attempted/limit numbers from +`PDFDocumentReader::getErrorMessage()` (see `PDFBudgetExceededException`'s +constructor in `pdfprocessingbudget.cpp` for the exact format). Regenerate +the corpus with: + +``` +python3 -m scripts.resource_envelope.budget_exhaustion_corpus \ + --output-dir UnitTests/testdata/budget_exhaustion +``` + Under memory pressure, `PDFDocumentSession::shedPrefetchAndQuality()` shrinks compile and stream cache caps. The Quick `DocumentViewSession` owns a 256 MiB unified page-cache total, partitioned into compiled-page and admitted-surface diff --git a/docs/RESOURCE_ENVELOPE.md b/docs/RESOURCE_ENVELOPE.md index 077351275..3d5362e16 100644 --- a/docs/RESOURCE_ENVELOPE.md +++ b/docs/RESOURCE_ENVELOPE.md @@ -102,3 +102,70 @@ python scripts/resource_envelope/pathological_workload.py ` Use `scripts/resource_envelope/validate_envelope.py` to validate a record against the checked-in limits before attaching it to a qualification dossier. + +For strict qualification, create a manifest containing the exact `sha256`, +`size_bytes`, and provenance for each external PDF, then pass it with +`--manifest`. Relative paths are resolved relative to the manifest file. The +runner records the Windows peak commit value as +`process_commit_high_water_bytes`; Linux keeps that field at `-1` because +virtual-size high water is not equivalent to process commit. + +## Run the fixture matrix + +Write `C:\temp\resource-envelope-fixtures.json` using the checked-in + +The helper records exact digests and sizes: + +```powershell +python scripts/resource_envelope/create_fixture_manifest.py ` + --fixture office-2mb=C:\fixtures\office-2mb.pdf ` + --fixture image-heavy-500mb=C:\fixtures\image-heavy-500mb.pdf ` + --fixture ten-thousand-page=C:\temp\loop-div2k-10000-pages.pdf ` + --fixture pathological-vector=C:\temp\loop-pathological-vector.pdf ` + --fixture transparency-spots=C:\temp\loop-transparency-spots.pdf ` + --provenance "release fixture bundle 2026-08" ` + --output C:\temp\resource-envelope-fixtures.json +``` + +The issue #242 matrix is run against externally stored PDFs so large fixtures +do not enter the repository. The multi-GB fixture is optional when the +platform or available disk cannot support it. The manifest has this shape: + +```json +{ + "schema_kind": "loop-resource-envelope-fixtures", + "schema_version": 1, + "fixtures": [ + { + "fixture_id": "pathological-vector", + "path": "C:\\temp\\loop-pathological-vector.pdf", + "sha256": "<64 lowercase hex characters>", + "size_bytes": 123456, + "provenance": "pathological_workload.py --family pathological-vector" + } + ] +} +``` + +Then run the strict qualification profile. Use `--repetitions 3` for the +recommended cold-process timing/RSS sample: + +```powershell +python scripts/resource_envelope/run_matrix.py ` + --pdf-tool C:\path\to\PdfTool.exe ` + --manifest C:\temp\resource-envelope-fixtures.json ` + --repetitions 3 --rasterizers 8 --strict ` + --output C:\temp\resource-envelope-matrix.json +``` + +Each fixture attempt records its input digest, exact PdfTool command, process +exit code, individual envelopes, conservative peak-RSS statistics, validation +errors, and optional baseline regressions. Missing, timed-out, or incomplete +measurements remain flagged in the JSON; they are never converted to zero or +reported as a passing complete run. Add +`--baseline C:\previous\resource-envelope-matrix.json` to compare matching +fixture digests and platform/toolchain identities. The default regression +margin is `2.0`; use a narrower margin only after collecting stable platform +baselines. Add `--cancel-fixture pathological-vector +--cancel-after-seconds 1` to send an interrupt to one controlled probe and +record the application's cancellation latency. diff --git a/docs/RESOURCE_ENVELOPE_QUALIFICATION.md b/docs/RESOURCE_ENVELOPE_QUALIFICATION.md index 6c25f37d5..f29358e59 100644 --- a/docs/RESOURCE_ENVELOPE_QUALIFICATION.md +++ b/docs/RESOURCE_ENVELOPE_QUALIFICATION.md @@ -19,10 +19,18 @@ Quick product path is implemented in Phase 4. 1. Validate the external DIV2K corpus and generate one canonical manifest with `--hash-all`. 2. Build the deterministic 10,000-page image-heavy PDF and record its digest. -3. Run PdfTool benchmark profiles on Linux and Windows with the same manifest. -4. Run the integrated session/scheduler harness with the same workload identity. -5. Replay the bounded lifecycle trace corpus on both platforms. -6. Attach JSON results, digests, platform identities, and dispositions to the +3. Create an external fixture manifest using the schema at + `docs/schemas/resource-envelope-fixtures.schema.json`, then run + `scripts/resource_envelope/run_matrix.py --manifest ... --strict` with the 2 MB office, + image-heavy, 10,000-page, pathological-vector, and transparency/spot + fixtures. Supply the multi-GB fixture when platform addressability permits. + The strict job is expected to remain non-passing until the native benchmark + also supplies preflight and recovery measurements; unavailable fields must + not be promoted to zero. +4. Run PdfTool benchmark profiles on Linux and Windows with the same manifest. +5. Run the integrated session/scheduler harness with the same workload identity. +6. Replay the bounded lifecycle trace corpus on both platforms. +7. Attach JSON results, digests, platform identities, and dispositions to the candidate-SHA evidence dossier. No unavailable measurement may be converted to zero or treated as a pass. diff --git a/docs/REVISION_CONTEXT.md b/docs/REVISION_CONTEXT.md index 170f24023..b64cf732b 100644 --- a/docs/REVISION_CONTEXT.md +++ b/docs/REVISION_CONTEXT.md @@ -26,3 +26,20 @@ This is the deterministic unit-level form of the hostile-workload stress contract: render, preflight, thumbnail, and repair-plan producers may finish in any order, but only a result carrying the current revision may cross the presentation/cache boundary. + +`UnitTestsRevisionStress` runs the concurrent form of the same contract. +Render, preflight, thumbnail, and repair-plan jobs are in flight together +while the document is mutated and the effective profile changes at points the +producers do not observe, and the test asserts the four correctness +properties: zero stale findings applied, zero stale tiles presented past an +invalidation boundary, deterministic cancellation (cancelled work is terminal, +is never success, and publishes nothing), and no cache read returning a result +for the wrong revision. Zero stale results is a correctness requirement, not a +percentile, so a single admitted stale result fails the test. + +Timing alone cannot prove that the fence was exercised, so the test does not +rely on it: one phase submits results against the current revision and asserts +they are admitted and read back as current, and a second phase holds one +producer per job kind inside its work function, mutates the document underneath +all of them, and only then releases them - asserting both the consumer-side +rejection and the scheduler-side `Stale` outcome. diff --git a/docs/SEMANTIC_TRUST_ENGINE_ACCEPTANCE.md b/docs/SEMANTIC_TRUST_ENGINE_ACCEPTANCE.md index 94ee5ba0c..223289b9f 100644 --- a/docs/SEMANTIC_TRUST_ENGINE_ACCEPTANCE.md +++ b/docs/SEMANTIC_TRUST_ENGINE_ACCEPTANCE.md @@ -15,7 +15,7 @@ Implementation evidence commit: `7912493e234f1abad3b90f3b813616aeb9d1fd63` | Issue criterion | Implementation location | Test / audit | Windows evidence | Linux evidence | Exact SHA | Status | | --- | --- | --- | --- | --- | --- | --- | | #234 canonical reducer; PASS/FAIL/INCOMPLETE/ERROR, waivers, zero-finding budget exhaustion, distinct PdfTool exits | `LoopLibCore/sources/pdfpreflightverdict.h`, `PdfTool/pdftoolpreflight.cpp`, `LoopEditorPlugins/LoopPreflightPlugin/preflightreportmodel.cpp` and report dock | `UnitTestsPreflightVerdict`, `UnitTestsPreflightEngine`, `UnitTestsPreflightPlugin`, `UnitTestsOperatorAcceptance`; direct four-state PdfTool fixture matrix; semantic-trust source audit | 15/15 focused targets green; direct exits/states: pass 0, fail 1, incomplete 8, error 9; waiver/budget cases green | Not run in this session | `7912493e234f1abad3b90f3b813616aeb9d1fd63` | Open — Windows candidate evidence green; Linux and merged-SHA evidence open | -| #236 one artifact/revision authority; complete revision-bound jobs and stale rejection under concurrent mutation | `LoopLibCore/sources/pdfdocumentcontext.*`, `pdfjobscheduler.*`, cache-key types | `UnitTestsIdentitySeparation`, `UnitTestsDocumentSession`, `UnitTestsJobScheduler`; 32-round render/preflight/thumbnail/repair-plan stress | 15/15 focused targets green, including 32-round concurrent stale-result rejection | Not run in this session | `7912493e234f1abad3b90f3b813616aeb9d1fd63` | Open — Windows candidate evidence green; Linux and merged-SHA evidence open | +| #236 one artifact/revision authority; complete revision-bound jobs and stale rejection under concurrent mutation | `LoopLibCore/sources/pdfdocumentcontext.*`, `pdfjobscheduler.*`, cache-key types | `UnitTestsIdentitySeparation`, `UnitTestsDocumentSession`, `UnitTestsJobScheduler`, `UnitTestsRevisionStress`; 64-round concurrent render/preflight/thumbnail/repair-plan stress | 15/15 focused targets green, including 32-round concurrent stale-result rejection | Not run in this session | `7912493e234f1abad3b90f3b813616aeb9d1fd63` | Open — Windows candidate evidence green; Linux and merged-SHA evidence open | | #237 one durable provenance chain; seven kinds, tamper detection, rollback append, retention, live PdfTool flows | `LoopLibCore/sources/pdfoperationhistory.*`, `pdfoperationhistorystore.*`, `PdfTool/pdftoolpreflight.cpp`, `pdftoolrepair.cpp`, `pdftooladdbleed.cpp` | `UnitTestsOperationHistory`, `UnitTestsLifecycle`, `UnitTestsOperatorAcceptance::livePdfToolFlows_writeVerifiableProvenance`, independent SQLite probe, provenance source audit | 15/15 focused targets green; live preflight/add-bleed sidecars contain revision/profile/output digests and terminal status; SQLite integrity probe green | Not run in this session | `7912493e234f1abad3b90f3b813616aeb9d1fd63` | Open — generic `repair --operation add-bleed` still returned `repair.unexpected-change`; Linux and merged-SHA evidence open | | #238 one scheduler submission boundary; no new unmanaged launches; typed GUI handoff and platform cancellation proof | `LoopLibCore/sources/pdfjobscheduler.*`, `scripts/ci/check_unmanaged_async.py`, CI source-integrity jobs | `UnitTestsJobScheduler`, `UnitTestsWorkloadEnvelope`, unmanaged-async source audit | Scheduler/workload tests and source audit green; audit reports 13 known legacy product `QtConcurrent::run` call sites | Not run in this session | `7912493e234f1abad3b90f3b813616aeb9d1fd63` | Blocked — product-facing unmanaged launches remain and Windows/Linux cancellation proof is not complete | | #239 explicit save policy; destructive operations cannot append incrementally; source remains immutable; recovered output not approved | `LoopLibCore/sources/pdfsavepolicy.*`, writer policy integration, repair history | `UnitTestsRepairOperation`, `UnitTestsIncrementalSave`, live repair provenance; independent parser/signature validator required | Save-policy/repair tests green; signed annotation/metadata fixture preserves original signed prefix; no independent PDF parser/signature validator available | Not run in this session | `7912493e234f1abad3b90f3b813616aeb9d1fd63` | Blocked — independent parser/signature evidence absent | diff --git a/docs/UPSTREAM_DIVERGENCE.md b/docs/UPSTREAM_DIVERGENCE.md index f55021f4d..888d80471 100644 --- a/docs/UPSTREAM_DIVERGENCE.md +++ b/docs/UPSTREAM_DIVERGENCE.md @@ -23,7 +23,7 @@ and re-run the mapped tests. A clean merge is not verification. |------|----------------|----------|-------|-------| | Processing budgets | `PDFProcessingBudget` bounds decode, raster, and graph work; exhaustion is incomplete | No equivalent named pools | `UnitTestsProcessingBudget`, `UnitTestsBudgetExhaustion` | #242 / #243 | | Plugin ABI | Manifest ABI/capabilities inspected before `QPluginLoader::instance()`; packaged plugin dir only | Loads any plugin after `load()` | `UnitTestsPluginAbi` | #269 | -| Revision fence | `PDFRevisionIdentity` discards stale async/cache results | Viewer caches are not revision-fenced | `UnitTestsDocumentSession`, `UnitTestsJobScheduler` | #236 | +| Revision fence | `PDFRevisionIdentity` discards stale async/cache results | Viewer caches are not revision-fenced | `UnitTestsDocumentSession`, `UnitTestsJobScheduler`, `UnitTestsRevisionStress` | #236 | | Incremental save | Source digest mismatch refuses a silent rewrite | Writer may overwrite | `UnitTestsIncrementalSave` | #239 | | Render fidelity | Standard rendering reports cached overprint content as an explicit approximation; preflight and separation policies prohibit approximation | Standard renderer has no fidelity diagnostic | `UnitTestsOverprint` | #49 / #52 | diff --git a/docs/branch-policy.json b/docs/branch-policy.json index e3626be9b..a2cdc8565 100644 --- a/docs/branch-policy.json +++ b/docs/branch-policy.json @@ -3,6 +3,7 @@ "default_branch": "stable", "release_branch": "stable", "integration_branch": "dev", + "qualification_branch": "unstable", "topic_branch_source": "dev", "topic_branch_patterns": [ "gh-*", @@ -11,8 +12,13 @@ "chore/*", "docs/*" ], - "protected_branches": [ + "promotion_chain": [ "dev", + "unstable", + "stable" + ], + "protected_branches": [ + "unstable", "stable" ] } diff --git a/docs/generated/architecture-catalog.json b/docs/generated/architecture-catalog.json index 073f1777f..dbf2774f7 100644 --- a/docs/generated/architecture-catalog.json +++ b/docs/generated/architecture-catalog.json @@ -210,10 +210,16 @@ "branch_policy": { "default": "stable", "integration": "dev", - "protected": [ + "promotion_chain": [ "dev", + "unstable", "stable" ], + "protected": [ + "stable", + "unstable" + ], + "qualification": "unstable", "release": "stable", "topic_patterns": [ "chore/*", @@ -379,6 +385,7 @@ "UnitTestsBleedFixup", "UnitTestsBleedMarginProbe", "UnitTestsBleedStress", + "UnitTestsBudgetCorpus", "UnitTestsBudgetExhaustion", "UnitTestsCanvasParity", "UnitTestsContentEditor", @@ -429,11 +436,13 @@ "UnitTestsProfileIdentity", "UnitTestsQuickAccessibility", "UnitTestsQuickCanvas", + "UnitTestsQuickDocumentModel", "UnitTestsRedactVerifier", "UnitTestsRepairDiff", "UnitTestsRepairOperation", "UnitTestsRepairOperatorAcceptance", "UnitTestsResourceBudget", + "UnitTestsRevisionStress", "UnitTestsRgbToCmyk", "UnitTestsSafeFileWriter", "UnitTestsSchemaEvolution", @@ -454,15 +463,18 @@ "workflow_branches": { ".github/workflows/ci.yml": [ "dev", - "stable" + "stable", + "unstable" ], ".github/workflows/codeql.yml": [ "dev", - "stable" + "stable", + "unstable" ], ".github/workflows/documentation.yml": [ "dev", - "stable" + "stable", + "unstable" ], ".github/workflows/release-gate.yml": [ "stable" diff --git a/docs/generated/phase5-widgets-inventory.json b/docs/generated/phase5-widgets-inventory.json index 840ad16cc..2004daf98 100644 --- a/docs/generated/phase5-widgets-inventory.json +++ b/docs/generated/phase5-widgets-inventory.json @@ -84,6 +84,8 @@ "UnitTests/CMakeLists.txt", "UnitTests/CMakeLists.txt", "UnitTests/CMakeLists.txt", + "UnitTests/CMakeLists.txt", + "UnitTests/CMakeLists.txt", "loop-preflight/tools/CMakeLists.txt" ], "shell_ledger": "docs/loop-shell.json", @@ -425,6 +427,7 @@ "UnitTestsBenchmarkIdentity", "UnitTestsBleedFixup", "UnitTestsBleedMarginProbe", + "UnitTestsBudgetCorpus", "UnitTestsBudgetExhaustion", "UnitTestsContentEditor", "UnitTestsContentProcessorLimits", @@ -461,6 +464,7 @@ "UnitTestsRepairOperation", "UnitTestsRepairOperatorAcceptance", "UnitTestsResourceBudget", + "UnitTestsRevisionStress", "UnitTestsRgbToCmyk", "UnitTestsSafeFileWriter", "UnitTestsSchemaEvolution", @@ -1054,6 +1058,46 @@ "widgets_paths": [], "consumers": [] }, + { + "id": "UnitTestsBudgetCorpus", + "kind": "executable", + "cmake": "UnitTests/CMakeLists.txt", + "profile_enabled": false, + "profile_condition": "qualification target excluded from the product-surface manifest", + "install_rule": false, + "installed_in_profile": false, + "build_only_in_profile": false, + "direct_links": [ + "LoopLibCore", + "Qt6::Core", + "Qt6::Gui", + "Qt6::Test" + ], + "direct_qt_modules": [ + "Core", + "Gui", + "Test" + ], + "transitive_targets": [ + "LoopLibCore" + ], + "transitive_qt_modules": [ + "Sql", + "Svg", + "Xml" + ], + "qt_modules": [ + "Core", + "Gui", + "Sql", + "Svg", + "Test", + "Xml" + ], + "widgets_linkage": "none", + "widgets_paths": [], + "consumers": [] + }, { "id": "UnitTestsBudgetExhaustion", "kind": "executable", @@ -2635,6 +2679,46 @@ "widgets_paths": [], "consumers": [] }, + { + "id": "UnitTestsRevisionStress", + "kind": "executable", + "cmake": "UnitTests/CMakeLists.txt", + "profile_enabled": false, + "profile_condition": "qualification target excluded from the product-surface manifest", + "install_rule": false, + "installed_in_profile": false, + "build_only_in_profile": false, + "direct_links": [ + "LoopLibCore", + "Qt6::Core", + "Qt6::Gui", + "Qt6::Test" + ], + "direct_qt_modules": [ + "Core", + "Gui", + "Test" + ], + "transitive_targets": [ + "LoopLibCore" + ], + "transitive_qt_modules": [ + "Sql", + "Svg", + "Xml" + ], + "qt_modules": [ + "Core", + "Gui", + "Sql", + "Svg", + "Test", + "Xml" + ], + "widgets_linkage": "none", + "widgets_paths": [], + "consumers": [] + }, { "id": "UnitTestsRgbToCmyk", "kind": "executable", @@ -2996,7 +3080,7 @@ } ], "counts": { - "targets": 68, + "targets": 70, "installed_in_profile": 4, "build_only_in_profile": 3, "widgets_surfaces": 4, diff --git a/docs/loop-shell-actions.json b/docs/loop-shell-actions.json index ca18b5650..10e99976b 100644 --- a/docs/loop-shell-actions.json +++ b/docs/loop-shell-actions.json @@ -536,9 +536,9 @@ "standard_key": "Find" }, "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" } }, { @@ -551,9 +551,9 @@ "standard_key": "FindNext" }, "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" } }, { @@ -566,9 +566,9 @@ "standard_key": "FindPrevious" }, "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" } }, { @@ -629,9 +629,9 @@ "sequence": "Ctrl+L" }, "parameters": [], - "capability": "unclassified", + "capability": "application", "cancellable": false, - "availability": "declared" + "availability": "implemented" } }, { @@ -881,9 +881,9 @@ "command": { "label_key": "command.actionPageLayoutContinuous.label", "parameters": [], - "capability": "unclassified", + "capability": "none", "cancellable": false, - "availability": "declared" + "availability": "implemented" } }, { @@ -893,9 +893,9 @@ "command": { "label_key": "command.actionPageLayoutSinglePage.label", "parameters": [], - "capability": "unclassified", + "capability": "none", "cancellable": false, - "availability": "declared" + "availability": "implemented" } }, { @@ -905,9 +905,9 @@ "command": { "label_key": "command.actionPageLayoutTwoColumns.label", "parameters": [], - "capability": "unclassified", + "capability": "none", "cancellable": false, - "availability": "declared" + "availability": "implemented" } }, { @@ -917,9 +917,9 @@ "command": { "label_key": "command.actionPageLayoutTwoPages.label", "parameters": [], - "capability": "unclassified", + "capability": "none", "cancellable": false, - "availability": "declared" + "availability": "implemented" } }, { @@ -944,9 +944,9 @@ "command": { "label_key": "command.actionProperties.label", "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" } }, { diff --git a/docs/schemas/agent-policy.schema.json b/docs/schemas/agent-policy.schema.json index 56973a7c7..ef93723c2 100644 --- a/docs/schemas/agent-policy.schema.json +++ b/docs/schemas/agent-policy.schema.json @@ -9,13 +9,19 @@ "qt_minimum": {"type": "string"}, "branches": { "type": "object", - "required": ["default", "integration", "release", "topic_source", "protected"], + "required": ["default", "integration", "qualification", "release", "topic_source", "promotion_chain", "protected"], "properties": { "default": {"type": "string"}, "integration": {"type": "string"}, + "qualification": {"type": "string"}, "release": {"type": "string"}, "topic_source": {"type": "string"}, "topic_branch_patterns": {"type": "array", "items": {"type": "string"}}, + "promotion_chain": { + "type": "array", + "items": {"type": "string"}, + "minItems": 2 + }, "protected": {"type": "array", "items": {"type": "string"}, "minItems": 1} } }, diff --git a/docs/schemas/interaction-scenario.schema.json b/docs/schemas/interaction-scenario.schema.json new file mode 100644 index 000000000..1d9a7bb0a --- /dev/null +++ b/docs/schemas/interaction-scenario.schema.json @@ -0,0 +1,299 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/studio-berry/loop/schema/interaction-scenario-v1.json", + "title": "Loop interaction regression scenario", + "description": "One replayable direct-manipulation scenario for issue #146. The recorded input lives under 'trace' in exactly the shape InteractionTrace::toJson() emits, or is generated from 'input_script'; everything else declares the fixture, the synthetic cost model, the budgets, and the expected end state. Scenario payload is deliberately kept out of InteractionTrace itself, whose privacy contract forbids geometry and target identity.", + "type": "object", + "required": [ + "schema_kind", + "schema_version", + "scenario_id", + "description", + "fixture", + "cost_model", + "budgets", + "expected" + ], + "properties": { + "schema_kind": { "const": "loop-interaction-scenario" }, + "schema_version": { "const": 1 }, + "scenario_id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "description": { "type": "string", "minLength": 1 }, + + "fixture": { + "type": "object", + "required": [ + "page_count", + "page_size_mm", + "pixel_per_mm", + "device_pixel_ratio", + "initial_zoom", + "viewport_size_px", + "page_layout" + ], + "properties": { + "page_count": { "type": "integer", "minimum": 1 }, + "page_size_mm": { "$ref": "#/$defs/size" }, + "pixel_per_mm": { "type": "number", "exclusiveMinimum": 0 }, + "device_pixel_ratio": { "type": "number", "exclusiveMinimum": 0 }, + "initial_zoom": { "type": "number", "exclusiveMinimum": 0 }, + "viewport_size_px": { "$ref": "#/$defs/sizeInt" }, + "page_layout": { + "enum": [ + "single-page", + "one-column", + "two-pages-left", + "two-pages-right", + "two-column-left", + "two-column-right" + ] + }, + "hit_targets": { "type": "array", "items": { "$ref": "#/$defs/hitTarget" } }, + "generated_targets": { + "description": "A dense page is declared as a grid rather than listed. Four thousand literal targets is a diff nobody reviews.", + "type": "object", + "required": ["kind", "count", "page_index", "grid"], + "properties": { + "kind": { "$ref": "#/$defs/targetKind" }, + "count": { "type": "integer", "minimum": 1, "maximum": 100000 }, + "page_index": { "type": "integer", "minimum": 0 }, + "id_prefix": { "type": "string", "minLength": 1 }, + "grid": { + "type": "object", + "required": ["columns", "rows", "origin", "stride", "size"], + "properties": { + "columns": { "type": "integer", "minimum": 1 }, + "rows": { "type": "integer", "minimum": 1 }, + "origin": { "$ref": "#/$defs/point" }, + "stride": { "$ref": "#/$defs/size" }, + "size": { "$ref": "#/$defs/size" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "guides": { "type": "array", "items": { "$ref": "#/$defs/guide" } }, + "snapping": { + "type": "object", + "required": ["enabled"], + "properties": { + "enabled": { "type": "boolean" }, + "screen_threshold_px": { "type": "number", "minimum": 0 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + + "async": { + "description": "Job actions applied at a given index in the input stream, so 'drag during preflight' happens at the same input on every runner.", + "type": "object", + "required": ["jobs"], + "properties": { + "jobs": { + "type": "array", + "items": { + "type": "object", + "required": ["at_input_index", "action"], + "properties": { + "at_input_index": { "type": "integer", "minimum": 0 }, + "action": { + "enum": ["submit", "pump", "cancel", "fail", "stale-result", "bump-revision"] + }, + "kind": { + "enum": ["rendering", "preflight", "ocr", "export", "thumbnail", "batch", "agent", "other"] + }, + "count": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + + "cost_model": { + "description": "Synthetic per-stage costs in nanoseconds, multiplied by real run products (index candidates, overlay primitives, cache misses). The deterministic lane reads no real clock, so these are what make latency assertable and byte-identical across machines.", + "type": "object", + "required": ["base_frame_ns"], + "properties": { + "base_frame_ns": { "type": "integer", "minimum": 0 }, + "hit_test_ns_per_candidate": { "type": "integer", "minimum": 0 }, + "overlay_ns_per_primitive": { "type": "integer", "minimum": 0 }, + "page_surface_admit_ns": { "type": "integer", "minimum": 0 }, + "cache_miss_ns": { "type": "integer", "minimum": 0 }, + "external_present_ns": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + }, + + "budgets": { + "type": "object", + "required": ["refresh_rate_hz", "frame_p95_ms", "input_to_frame_p95_ms"], + "properties": { + "refresh_rate_hz": { "type": "number", "minimum": 0 }, + "frame_p95_ms": { "type": "number", "exclusiveMinimum": 0 }, + "input_to_frame_p95_ms": { "type": "number", "exclusiveMinimum": 0 }, + "max_slow_frames": { "type": "integer", "minimum": 0 }, + "max_dropped_frames": { "type": "integer", "minimum": 0 }, + "variance_band_multiplier": { + "description": "Applied by the desktop/GPU lane only. The deterministic lane is strict and ignores it.", + "type": "number", + "minimum": 1 + } + }, + "additionalProperties": false + }, + + "expected": { + "description": "Final interaction and document state (AC2). An empty string asserts 'nothing selected' or 'nothing hovered', which is distinct from the key being absent.", + "type": "object", + "properties": { + "selected_id": { "type": "string" }, + "hover_id": { "type": "string" }, + "zoom": { "type": "number", "exclusiveMinimum": 0 }, + "current_page": { "type": "integer", "minimum": 0 }, + "scroll_offset_px": { "$ref": "#/$defs/pointInt" }, + "drag_completed": { "type": "integer", "minimum": 0 }, + "snapped_to": { "type": "string" }, + "request_generation_changed": { "type": "boolean" }, + "cancellations": { + "type": "array", + "items": { "$ref": "#/$defs/cancelReason" } + }, + "counters": { + "type": "object", + "additionalProperties": { "type": "integer", "minimum": 0 } + }, + "unbalanced_frames": { "type": "integer", "minimum": 0 }, + "pending_inputs": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + }, + + "trace": { + "description": "Verbatim InteractionTrace::toJson() output. Present when the scenario is a recorded session; mutually exclusive with input_script.", + "type": "object" + }, + + "input_script": { + "description": "Generated input, for scenarios where a literal trace would be hundreds of near-identical records. Mutually exclusive with trace.", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["kind"], + "properties": { + "kind": { + "enum": ["pointer-move", "pointer-press", "pointer-release", "wheel", "key", "notification", "hover-sweep"] + }, + "at_px": { "$ref": "#/$defs/pointInt" }, + "to_px": { "$ref": "#/$defs/pointInt" }, + "steps": { "type": "integer", "minimum": 1 }, + "interval_ns": { "type": "integer", "minimum": 1 }, + "button": { "enum": ["left", "right", "middle", "none"] }, + "modifiers": { + "type": "array", + "items": { "enum": ["shift", "control", "alt", "meta"] } + }, + "angle_delta": { "type": "integer" }, + "key": { "type": "integer" }, + "notification": { "$ref": "#/$defs/notification" } + }, + "additionalProperties": false + } + } + }, + + "oneOf": [ + { "required": ["trace"] }, + { "required": ["input_script"] } + ], + + "additionalProperties": false, + + "$defs": { + "point": { + "type": "object", + "required": ["x", "y"], + "properties": { "x": { "type": "number" }, "y": { "type": "number" } }, + "additionalProperties": false + }, + "pointInt": { + "type": "object", + "required": ["x", "y"], + "properties": { "x": { "type": "integer" }, "y": { "type": "integer" } }, + "additionalProperties": false + }, + "size": { + "type": "object", + "required": ["width", "height"], + "properties": { + "width": { "type": "number", "exclusiveMinimum": 0 }, + "height": { "type": "number", "exclusiveMinimum": 0 } + }, + "additionalProperties": false + }, + "sizeInt": { + "type": "object", + "required": ["width", "height"], + "properties": { + "width": { "type": "integer", "minimum": 1 }, + "height": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false + }, + "rect": { + "type": "object", + "required": ["x", "y", "width", "height"], + "properties": { + "x": { "type": "number" }, + "y": { "type": "number" }, + "width": { "type": "number", "minimum": 0 }, + "height": { "type": "number", "minimum": 0 } + }, + "additionalProperties": false + }, + "targetKind": { "enum": ["finding", "guide", "page-box", "page", "handle"] }, + "hitTarget": { + "type": "object", + "required": ["kind", "page_index", "id", "page_bounds"], + "properties": { + "kind": { "$ref": "#/$defs/targetKind" }, + "page_index": { "type": "integer", "minimum": 0 }, + "id": { "type": "string", "minLength": 1 }, + "page_bounds": { "$ref": "#/$defs/rect" } + }, + "additionalProperties": false + }, + "guide": { + "type": "object", + "required": ["id", "page_index", "orientation", "position"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "page_index": { "type": "integer", "minimum": 0 }, + "orientation": { "enum": ["horizontal", "vertical"] }, + "position": { "type": "number" } + }, + "additionalProperties": false + }, + "cancelReason": { + "enum": [ + "explicit", + "escape", + "pointer-cancelled", + "focus-lost", + "capture-lost", + "tool-changed", + "selection-changed", + "revision-changed", + "document-closed" + ] + }, + "notification": { + "enum": ["focus-lost", "capture-lost", "document-closed", "tool-changed"] + } + } +} diff --git a/docs/schemas/interaction-trace-report.schema.json b/docs/schemas/interaction-trace-report.schema.json new file mode 100644 index 000000000..51bb3803a --- /dev/null +++ b/docs/schemas/interaction-trace-report.schema.json @@ -0,0 +1,247 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/studio-berry/loop/schema/interaction-trace-report-v1.json", + "title": "Loop interaction trace regression report", + "description": "The CI evidence artifact for issue #146, emitted by the test binary rather than parsed out of ctest output. Missing telemetry is reported as available:false with null percentiles and never as zero, per docs/RESOURCE_BUDGETS.md.", + "type": "object", + "required": ["schema_kind", "schema_version", "lane", "identity", "runs"], + "properties": { + "schema_kind": { "const": "loop-interaction-trace-report" }, + "schema_version": { "const": 1 }, + "lane": { + "description": "deterministic is the gating lane with a synthetic clock; present is the desktop/GPU lane with real timing and variance bands.", + "enum": ["deterministic", "present"] + }, + "identity": { + "type": "object", + "required": [ + "commit", + "compiler", + "os", + "qt", + "cpu", + "renderer", + "fixture_digest", + "profile_or_operation_version", + "corpus_digest" + ], + "properties": { + "commit": { "type": "string", "minLength": 1 }, + "compiler": { "type": "string", "minLength": 1 }, + "os": { "type": "string", "minLength": 1 }, + "qt": { "type": "string", "minLength": 1 }, + "cpu": { "type": "string", "minLength": 1 }, + "renderer": { "type": "string", "minLength": 1 }, + "fixture_digest": { "type": "string", "minLength": 1 }, + "profile_or_operation_version": { "type": "string", "minLength": 1 }, + "corpus_digest": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + }, + "additionalProperties": false + }, + "runs": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/run" } + } + }, + "additionalProperties": false, + + "$defs": { + "run": { + "type": "object", + "required": [ + "scenario_id", + "status", + "trace_id", + "summary_schema_version", + "budgets", + "samples", + "input_to_frame_ms", + "frame_time_ms", + "stage_ms", + "slow_frame_causes", + "hit_test", + "async_overlap", + "page_surface_cache", + "present_timing", + "passed", + "first_violated_contract", + "responsible_phase", + "failure_excerpt" + ], + "properties": { + "scenario_id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "status": { + "description": "verified means the measurement actually happened; static-only means the contracts passed but present/GPU timing was unavailable; infrastructure-blocked means the lane could not run. A static-only run must never be reported as a verified measurement.", + "enum": ["verified", "static-only", "infrastructure-blocked"] + }, + "trace_id": { "type": "string" }, + "summary_schema_version": { "type": "integer", "minimum": 1 }, + "budgets": { + "type": "object", + "required": ["status", "reference_60_hz_ms", "reference_120_hz_ms", "applied"], + "properties": { + "status": { "enum": ["known", "unavailable"] }, + "refresh_rate_hz": { "type": ["number", "null"] }, + "frame_budget_ms": { "type": ["number", "null"] }, + "reference_60_hz_ms": { "type": "number" }, + "reference_120_hz_ms": { "type": "number" }, + "applied": { + "type": "object", + "required": ["mode", "frame_p95_ms", "input_to_frame_p95_ms"], + "properties": { + "mode": { "enum": ["strict", "variance-band"] }, + "frame_p95_ms": { "type": "number" }, + "input_to_frame_p95_ms": { "type": "number" }, + "variance_band_multiplier": { "type": ["number", "null"] } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "samples": { + "type": "object", + "required": ["inputs", "frames", "pending_inputs", "dropped_frames", "unbalanced_frames"], + "properties": { + "inputs": { "type": "integer", "minimum": 0 }, + "frames": { "type": "integer", "minimum": 0 }, + "pending_inputs": { "type": "integer", "minimum": 0 }, + "dropped_frames": { "type": "integer", "minimum": 0 }, + "unbalanced_frames": { "type": "integer", "minimum": 0 }, + "dropped_input_records": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + }, + "input_to_frame_ms": { "$ref": "#/$defs/durationPercentiles" }, + "frame_time_ms": { "$ref": "#/$defs/durationPercentiles" }, + "stage_ms": { + "type": "object", + "propertyNames": { "$ref": "#/$defs/stageName" }, + "additionalProperties": { "$ref": "#/$defs/durationPercentiles" } + }, + "slow_frame_causes": { + "type": "object", + "propertyNames": { "$ref": "#/$defs/stageName" }, + "additionalProperties": { "type": "integer", "minimum": 0 } + }, + "hit_test": { + "type": "object", + "required": ["index_candidates", "precise_hits", "duration_ms"], + "properties": { + "index_candidates": { "$ref": "#/$defs/countPercentiles" }, + "precise_hits": { "$ref": "#/$defs/countPercentiles" }, + "duration_ms": { "$ref": "#/$defs/durationPercentiles" } + }, + "additionalProperties": false + }, + "async_overlap": { + "type": "object", + "propertyNames": { "$ref": "#/$defs/jobKind" }, + "additionalProperties": { + "type": "object", + "required": ["frames_overlapped", "slow_frames_overlapped", "active"], + "properties": { + "frames_overlapped": { "type": "integer", "minimum": 0 }, + "slow_frames_overlapped": { "type": "integer", "minimum": 0 }, + "active": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + } + }, + "page_surface_cache": { + "type": "object", + "required": ["hits", "misses"], + "properties": { + "hits": { "type": "integer", "minimum": 0 }, + "misses": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + }, + "present_timing": { + "description": "A present record on a lane that cannot measure presentation reports available:false with a reason, never a zero p95.", + "oneOf": [ + { + "type": "object", + "required": ["available", "reason"], + "properties": { + "available": { "const": false }, + "reason": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + { + "allOf": [{ "$ref": "#/$defs/durationPercentiles" }], + "properties": { "available": { "const": true } } + } + ] + }, + "passed": { "type": "boolean" }, + "first_violated_contract": { + "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/contract" }] + }, + "responsible_phase": { + "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/phase" }] + }, + "failure_excerpt": { "type": "array", "items": { "type": "string", "minLength": 1 } } + }, + "additionalProperties": false + }, + + "durationPercentiles": { + "type": "object", + "required": ["available", "sample_count", "p50_ms", "p95_ms", "p99_ms"], + "properties": { + "available": { "type": "boolean" }, + "sample_count": { "type": "integer", "minimum": 0 }, + "p50_ms": { "type": ["number", "null"] }, + "p95_ms": { "type": ["number", "null"] }, + "p99_ms": { "type": ["number", "null"] } + }, + "additionalProperties": false + }, + "countPercentiles": { + "type": "object", + "required": ["available", "sample_count", "p50", "p95", "p99"], + "properties": { + "available": { "type": "boolean" }, + "sample_count": { "type": "integer", "minimum": 0 }, + "p50": { "type": ["number", "null"] }, + "p95": { "type": ["number", "null"] }, + "p99": { "type": ["number", "null"] } + }, + "additionalProperties": false + }, + "stageName": { + "enum": ["interaction", "hit-test", "overlay", "page-surface", "external", "unknown"] + }, + "jobKind": { + "enum": ["rendering", "preflight", "ocr", "export", "thumbnail", "batch", "agent", "other"] + }, + "contract": { + "description": "Evaluated in this order, so 'first violated' is a documented constant rather than JSON iteration order (AC7).", + "enum": [ + "input-acknowledged", + "frame-balance", + "telemetry-available", + "p95-input-to-frame", + "p95-frame-time", + "slow-frame-budget", + "dropped-frames", + "stale-result-safety", + "final-state" + ] + }, + "phase": { + "enum": [ + "input", + "hit-test", + "page-cache", + "overlay", + "composition", + "async-overlap", + "unknown" + ] + } + } +} diff --git a/docs/schemas/resource-envelope-fixtures.schema.json b/docs/schemas/resource-envelope-fixtures.schema.json new file mode 100644 index 000000000..3460e58e0 --- /dev/null +++ b/docs/schemas/resource-envelope-fixtures.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/studio-berry/loop/schema/resource-envelope-fixtures-v1.json", + "title": "Loop resource-envelope external fixture manifest", + "type": "object", + "required": ["schema_kind", "schema_version", "fixtures"], + "properties": { + "schema_kind": { "const": "loop-resource-envelope-fixtures" }, + "schema_version": { "const": 1 }, + "fixtures": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["fixture_id", "path", "sha256", "size_bytes", "provenance"], + "properties": { + "fixture_id": { + "enum": ["office-2mb", "image-heavy-500mb", "multi-gb", "ten-thousand-page", "pathological-vector", "transparency-spots"] + }, + "path": { "type": "string", "minLength": 1 }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "size_bytes": { "type": "integer", "minimum": 1 }, + "page_count": { "type": "integer", "minimum": 1 }, + "provenance": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/docs/schemas/workload-envelope.schema.json b/docs/schemas/workload-envelope.schema.json index 28b687dd1..ff8756b29 100644 --- a/docs/schemas/workload-envelope.schema.json +++ b/docs/schemas/workload-envelope.schema.json @@ -26,6 +26,7 @@ "page_count": { "type": "integer", "minimum": 0 }, "open_to_first_view_ms": { "type": "integer", "minimum": -1 }, "rss_high_water_bytes": { "type": "integer", "minimum": -1 }, + "process_commit_high_water_bytes": { "type": "integer", "minimum": -1 }, "cache_high_water_bytes": { "type": "integer", "minimum": -1 }, "preflight_high_water_bytes": { "type": "integer", "minimum": -1 }, "pages_materialized": { "type": "integer", "minimum": -1 }, diff --git a/scripts/agent/check-change.py b/scripts/agent/check-change.py index b70003e63..cfa8fe2cc 100644 --- a/scripts/agent/check-change.py +++ b/scripts/agent/check-change.py @@ -121,7 +121,7 @@ def current_branch(override: str | None) -> str: def policy_integration_branches(policy: dict) -> set[str]: branches = policy.get("branches", {}) names: set[str] = set(branches.get("protected") or []) - for key in ("integration", "release", "default"): + for key in ("integration", "qualification", "release", "default"): value = branches.get(key) if isinstance(value, str) and value: names.add(value) diff --git a/scripts/agent/generate-adapters.py b/scripts/agent/generate-adapters.py index 42381027e..01411a320 100644 --- a/scripts/agent/generate-adapters.py +++ b/scripts/agent/generate-adapters.py @@ -56,23 +56,24 @@ def load_policy() -> dict: def render(policy: dict, adapter: str) -> str: if adapter == "docs/branch-policy.json": branches = policy["branches"] - return json.dumps( - { - "generated_by": "scripts/agent/generate-adapters.py", - "default_branch": branches["default"], - "release_branch": branches["release"], - "integration_branch": branches["integration"], - "topic_branch_source": branches["topic_source"], - "topic_branch_patterns": branches["topic_branch_patterns"], - "protected_branches": branches["protected"] - }, - indent=2, - ) + "\n" + payload = { + "generated_by": "scripts/agent/generate-adapters.py", + "default_branch": branches["default"], + "release_branch": branches["release"], + "integration_branch": branches["integration"], + "qualification_branch": branches["qualification"], + "topic_branch_source": branches["topic_source"], + "topic_branch_patterns": branches["topic_branch_patterns"], + "promotion_chain": branches["promotion_chain"], + "protected_branches": branches["protected"], + } + return json.dumps(payload, indent=2) + "\n" branches = policy["branches"] autonomy = policy["autonomy"] changelog = policy["changelog"] version, prerelease = load_version_policy() display_version = format_product_version(version, prerelease) + promotion = " → ".join(f"`{branch}`" for branch in branches["promotion_chain"]) lines = [ "", "# Loop agent policy adapter", @@ -81,7 +82,7 @@ def render(policy: dict, adapter: str) -> str: "", "## Branches and safety", "", - f"- Integration: `{branches['integration']}`; release/default: `{branches['release']}`; topic branches start from `{branches['topic_source']}`.", + f"- Integration: `{branches['integration']}`; qualification: `{branches['qualification']}`; release/default: `{branches['release']}`; topic branches start from `{branches['topic_source']}`. Promotion: {promotion}.", f"- Protected branches: {', '.join(f'`{branch}`' for branch in branches['protected'])}. Do not commit, push, merge, force-push, or rewrite history without approval.", "- Keep private data, credentials, logs, scratch plans, and build artifacts outside the repository. Do not edit vendored dependencies unless explicitly scoped.", "", diff --git a/scripts/agent/test_check_change.py b/scripts/agent/test_check_change.py index 3f08617bc..67dbf4244 100644 --- a/scripts/agent/test_check_change.py +++ b/scripts/agent/test_check_change.py @@ -22,8 +22,9 @@ POLICY_BRANCHES = { "default": "stable", "integration": "dev", + "qualification": "unstable", "release": "stable", - "protected": ["dev", "stable"], + "protected": ["unstable", "stable"], } @@ -67,6 +68,7 @@ def test_skip_changelog_on_integration_branch(self) -> None: policy = {"branches": POLICY_BRANCHES} with patch.dict(os.environ, {"GITHUB_EVENT_NAME": ""}, clear=False): self.assertEqual(MODULE.skip_changelog_reason("dev", policy, False), "integration branch") + self.assertEqual(MODULE.skip_changelog_reason("unstable", policy, False), "integration branch") self.assertEqual(MODULE.skip_changelog_reason("stable", policy, False), "integration branch") self.assertIsNone(MODULE.skip_changelog_reason("cdx/foo", policy, False)) self.assertEqual(MODULE.skip_changelog_reason("cdx/foo", policy, True), "non-PR event") diff --git a/scripts/ci/check_branch_policy.py b/scripts/ci/check_branch_policy.py index d56d50ad4..9ace72d86 100644 --- a/scripts/ci/check_branch_policy.py +++ b/scripts/ci/check_branch_policy.py @@ -22,6 +22,7 @@ DOCUMENTED_CI_BRANCHES = re.compile(r"^[-*]\s+CI branches:\s*(.+)$", re.MULTILINE) DOCUMENTED_PROTECTED_BRANCHES = re.compile(r"^[-*]\s+Protected branches:\s*(.+)$", re.MULTILINE) +DOCUMENTED_PROMOTION_CHAIN = re.compile(r"^[-*]\s+Promotion chain:\s*(.+)$", re.MULTILINE) DOCUMENTED_REQUIRED_CHECK = re.compile(r"^[-*]\s+Required check:\s*`([^`]+)`$", re.MULTILINE) DOCUMENTED_INTEGRATION_REQUIRED_CHECK = re.compile(r"^[-*]\s+Required integration check:\s*`([^`]+)`$", re.MULTILINE) DOCUMENTED_REQUIRED_CHECK_APP = re.compile(r"^[-*]\s+Required check app:\s*(.+)$", re.MULTILINE) @@ -46,6 +47,7 @@ class DocumentedPolicy: ci_branches: tuple[str, ...] protected_branches: tuple[str, ...] + promotion_chain: tuple[str, ...] required_check: str integration_required_check: str required_check_app: str @@ -90,6 +92,9 @@ def required(pattern: re.Pattern[str], label: str) -> str: protected = _branch_names(required(DOCUMENTED_PROTECTED_BRANCHES, "Protected branches:")) if not protected: raise ValueError("policy declares no protected branches") + promotion_chain = _branch_names(required(DOCUMENTED_PROMOTION_CHAIN, "Promotion chain:")) + if not promotion_chain: + raise ValueError("policy declares no promotion chain") required_check = required(DOCUMENTED_REQUIRED_CHECK, "Required check:") integration_required_check = required( DOCUMENTED_INTEGRATION_REQUIRED_CHECK, "Required integration check:" @@ -113,6 +118,7 @@ def required(pattern: re.Pattern[str], label: str) -> str: return DocumentedPolicy( ci_branches=ci_branches, protected_branches=protected, + promotion_chain=promotion_chain, required_check=required_check, integration_required_check=integration_required_check, required_check_app=required_app, @@ -376,6 +382,7 @@ def _validate_required_check( def validate_live_protection( *, stable_protection: dict[str, Any] | None, + unstable_protection: dict[str, Any] | None, dev_protection: dict[str, Any] | None, policy: DocumentedPolicy, ) -> list[str]: @@ -390,16 +397,23 @@ def validate_live_protection( required_check_app=policy.required_check_app, ) ) - if "dev" in policy.protected_branches: + if "unstable" in policy.protected_branches: violations.extend( _validate_required_check( - branch="dev", - protection=dev_protection, + branch="unstable", + protection=unstable_protection, expected=policy.integration_required_check, required_check_app=policy.required_check_app, ) ) - elif isinstance(dev_protection, dict): + elif isinstance(unstable_protection, dict): + contexts = [str(item.get("context")) for item in _required_check_entries(unstable_protection)] + if contexts: + violations.append( + "live protection: unstable must not require status checks, " + f"got {contexts}" + ) + if "dev" not in policy.protected_branches and isinstance(dev_protection, dict): contexts = [str(item.get("context")) for item in _required_check_entries(dev_protection)] if contexts: violations.append( @@ -496,8 +510,9 @@ def validate_repository( ) else: stable, stable_error = fetch_branch_protection(repo_name, "stable", auth) + unstable, unstable_error = fetch_branch_protection(repo_name, "unstable", auth) dev, dev_error = fetch_branch_protection(repo_name, "dev", auth) - if stable_error == "403" or dev_error == "403": + if stable_error == "403" or unstable_error == "403" or dev_error == "403": print( "WARNING: live branch protection is not readable with this token; " "file-based policy checks still ran.", @@ -508,6 +523,10 @@ def validate_repository( violations.append( f"live protection: failed to read stable rules ({stable_error})" ) + if unstable_error and unstable_error != "404": + violations.append( + f"live protection: failed to read unstable rules ({unstable_error})" + ) if dev_error and dev_error != "404": violations.append( f"live protection: failed to read dev rules ({dev_error})" @@ -516,6 +535,7 @@ def validate_repository( violations.extend( validate_live_protection( stable_protection=stable, + unstable_protection=unstable if unstable_error != "404" else {}, dev_protection=dev if dev_error != "404" else {}, policy=policy, ) @@ -538,7 +558,7 @@ def main() -> int: return 1 print( "Branch policy passed: workflow triggers match the documented " - "dev/stable contract." + "dev/unstable/stable contract." ) return 0 diff --git a/scripts/ci/check_interaction_traces.py b/scripts/ci/check_interaction_traces.py new file mode 100755 index 000000000..6011afad0 --- /dev/null +++ b/scripts/ci/check_interaction_traces.py @@ -0,0 +1,668 @@ +#!/usr/bin/env python3 +"""Validate the interaction trace corpus and the CI trace report (issue #146). + +Two jobs, deliberately in one script because they share the corpus: + + --corpus-only validate every scenario against the scenario schema and the + manifest digests. Needs no build, so a malformed scenario + fails in seconds rather than after a compile. + (default) validate a report emitted by the trace test binary: identity, + scenario coverage, and the rule that missing telemetry is + reported as unavailable rather than as zero. + +The schema check is hand-rolled against the tracked JSON Schema documents +because the repository pins no jsonschema dependency; check_fuzz_corpus.py and +scripts/resource_envelope/validate_envelope.py take the same approach. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +CORPUS_DIR = ROOT / "UnitTests" / "testdata" / "interaction-traces" +MANIFEST_PATH = CORPUS_DIR / "manifest.json" +SCENARIO_SCHEMA = ROOT / "docs" / "schemas" / "interaction-scenario.schema.json" +REPORT_SCHEMA = ROOT / "docs" / "schemas" / "interaction-trace-report.schema.json" + +KEBAB_CASE = re.compile(r"^[a-z][a-z0-9-]*$") +SHA256 = re.compile(r"^[0-9a-f]{64}$") + +# Mirrors docs/schemas/interaction-trace-report.schema.json. Kept as literals +# rather than parsed out of the schema so that a schema edit that drops a value +# fails a test instead of silently widening the checker. +CONTRACTS = ( + "input-acknowledged", + "frame-balance", + "telemetry-available", + "p95-input-to-frame", + "p95-frame-time", + "slow-frame-budget", + "dropped-frames", + "stale-result-safety", + "final-state", +) +PHASES = ( + "input", + "hit-test", + "page-cache", + "overlay", + "composition", + "async-overlap", + "unknown", +) +STATUSES = ("verified", "static-only", "infrastructure-blocked") +LANES = ("deterministic", "present") +IDENTITY_FIELDS = ( + "commit", + "compiler", + "os", + "qt", + "cpu", + "renderer", + "fixture_digest", + "profile_or_operation_version", + "corpus_digest", +) + +REQUIRED_SCENARIO_FIELDS = frozenset( + { + "schema_kind", + "schema_version", + "scenario_id", + "description", + "fixture", + "cost_model", + "budgets", + "expected", + } +) +REQUIRED_FIXTURE_FIELDS = frozenset( + { + "page_count", + "page_size_mm", + "pixel_per_mm", + "device_pixel_ratio", + "initial_zoom", + "viewport_size_px", + "page_layout", + } +) +REQUIRED_BUDGET_FIELDS = frozenset( + {"refresh_rate_hz", "frame_p95_ms", "input_to_frame_p95_ms"} +) + +Violation = tuple[str, str] + + +def sha256_file(path: Path) -> str: + """SHA-256 of a scenario file, over line-ending-normalized bytes. + + .gitattributes checks this repository's text out as CRLF by default, and + pins the corpus back to LF so the digests stay stable. Normalizing here as + well means a checkout that lost that pin -- a zip export, a contributor with + a global setting -- reports a real corpus edit rather than a line-ending + difference nobody made. These files are JSON, so their bytes carry no + meaning a newline conversion can destroy. + """ + digest = hashlib.sha256() + with path.open("rb") as handle: + payload = handle.read() + digest.update(payload.replace(b"\r\n", b"\n")) + return digest.hexdigest() + + +def corpus_digest(manifest: dict) -> str: + """A digest over the manifest's scenario digests, in id order. + + This is the value a report's identity.corpus_digest must carry, so a report + produced against a different corpus cannot be compared to this one. + """ + joined = "\n".join( + f"{entry.get('id')}:{entry.get('sha256')}" + for entry in sorted(manifest.get("scenarios", []), key=lambda e: str(e.get("id"))) + ) + return hashlib.sha256(joined.encode("utf-8")).hexdigest() + + +def load_manifest(corpus_dir: Path = CORPUS_DIR) -> dict: + """Load and return the corpus manifest.""" + with (corpus_dir / "manifest.json").open(encoding="utf-8") as handle: + return json.load(handle) + + +def validate_scenario(scenario: dict, label: str) -> list[Violation]: + """Return (subject, reason) for every scenario-document violation.""" + violations: list[Violation] = [] + + if scenario.get("schema_kind") != "loop-interaction-scenario": + violations.append((label, "schema_kind must be loop-interaction-scenario")) + if scenario.get("schema_version") != 1: + violations.append((label, "schema_version must be 1")) + + missing = REQUIRED_SCENARIO_FIELDS - scenario.keys() + if missing: + violations.append((label, f"missing required fields: {sorted(missing)}")) + return violations + + scenario_id = scenario["scenario_id"] + if not isinstance(scenario_id, str) or not KEBAB_CASE.match(scenario_id): + violations.append((label, f"scenario_id must be kebab-case, got {scenario_id!r}")) + + if not str(scenario.get("description", "")).strip(): + violations.append((label, "description must not be empty")) + + has_trace = "trace" in scenario + has_script = "input_script" in scenario + if has_trace == has_script: + violations.append( + (label, "exactly one of 'trace' or 'input_script' is required") + ) + + if has_trace: + trace = scenario["trace"] + if not isinstance(trace, dict): + violations.append((label, "trace must be an object")) + else: + if trace.get("schema_version") != 1: + violations.append((label, "trace.schema_version must be 1")) + if not isinstance(trace.get("inputs"), list) or not trace["inputs"]: + violations.append((label, "trace.inputs must be a non-empty array")) + + if has_script: + script = scenario["input_script"] + if not isinstance(script, list) or not script: + violations.append((label, "input_script must be a non-empty array")) + + fixture = scenario["fixture"] + if not isinstance(fixture, dict): + violations.append((label, "fixture must be an object")) + else: + fixture_missing = REQUIRED_FIXTURE_FIELDS - fixture.keys() + if fixture_missing: + violations.append( + (label, f"fixture missing required fields: {sorted(fixture_missing)}") + ) + if not isinstance(fixture.get("page_count"), int) or fixture.get("page_count", 0) < 1: + violations.append((label, "fixture.page_count must be a positive integer")) + for key in ("pixel_per_mm", "device_pixel_ratio", "initial_zoom"): + value = fixture.get(key) + if not isinstance(value, (int, float)) or value <= 0: + violations.append((label, f"fixture.{key} must be greater than zero")) + + seen_target_ids: set[str] = set() + for index, target in enumerate(fixture.get("hit_targets", []) or []): + if not isinstance(target, dict): + violations.append((label, f"hit_targets[{index}] must be an object")) + continue + target_id = target.get("id") + if not isinstance(target_id, str) or not target_id: + violations.append((label, f"hit_targets[{index}] needs a non-empty id")) + elif target_id in seen_target_ids: + violations.append((label, f"duplicate hit target id {target_id!r}")) + else: + seen_target_ids.add(target_id) + + cost_model = scenario["cost_model"] + if not isinstance(cost_model, dict) or "base_frame_ns" not in cost_model: + violations.append((label, "cost_model must be an object with base_frame_ns")) + else: + for key, value in cost_model.items(): + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + violations.append( + (label, f"cost_model.{key} must be a non-negative integer of nanoseconds") + ) + + budgets = scenario["budgets"] + if not isinstance(budgets, dict): + violations.append((label, "budgets must be an object")) + else: + budget_missing = REQUIRED_BUDGET_FIELDS - budgets.keys() + if budget_missing: + violations.append( + (label, f"budgets missing required fields: {sorted(budget_missing)}") + ) + for key in ("frame_p95_ms", "input_to_frame_p95_ms"): + value = budgets.get(key) + if not isinstance(value, (int, float)) or value <= 0: + violations.append((label, f"budgets.{key} must be greater than zero")) + band = budgets.get("variance_band_multiplier") + if band is not None and (not isinstance(band, (int, float)) or band < 1): + violations.append((label, "budgets.variance_band_multiplier must be at least 1")) + + if not isinstance(scenario["expected"], dict): + violations.append((label, "expected must be an object")) + + return violations + + +def validate_corpus(corpus_dir: Path = CORPUS_DIR, root: Path = ROOT) -> list[Violation]: + """Return (subject, reason) for every corpus violation.""" + try: + manifest = load_manifest(corpus_dir) + except (OSError, json.JSONDecodeError) as exc: + return [("manifest.json", f"unable to load manifest: {exc}")] + + violations: list[Violation] = [] + + if manifest.get("schema_kind") != "loop-interaction-corpus": + violations.append(("manifest.json", "schema_kind must be loop-interaction-corpus")) + if manifest.get("schema_version") != 1: + violations.append(("manifest.json", "schema_version must be 1")) + return violations + + entries = manifest.get("scenarios") + if not isinstance(entries, list) or not entries: + violations.append(("manifest.json", "scenarios must be a non-empty array")) + return violations + + seen_ids: set[str] = set() + manifest_paths: set[str] = set() + + for index, entry in enumerate(entries): + label = f"scenarios[{index}]" + if not isinstance(entry, dict): + violations.append((label, "scenario entry must be an object")) + continue + + missing = {"id", "path", "issue", "sha256"} - entry.keys() + if missing: + violations.append((label, f"missing required fields: {sorted(missing)}")) + continue + + blocked_on = entry.get("blocked_on") + if blocked_on is not None: + # A scenario may be reviewed as data before the harness can run it. + # Saying so explicitly is what keeps the coverage check strict for + # everything else: without this the check would have to be relaxed + # for the whole corpus. + if not isinstance(blocked_on, str) or not blocked_on.strip(): + violations.append((label, "blocked_on must be a non-empty string when present")) + if not str(entry.get("blocked_reason", "")).strip(): + violations.append((label, "a blocked scenario must carry a blocked_reason")) + + entry_id = entry["id"] + if not isinstance(entry_id, str) or not KEBAB_CASE.match(entry_id): + violations.append((label, f"id must be kebab-case, got {entry_id!r}")) + elif entry_id in seen_ids: + violations.append((label, f"duplicate id {entry_id!r}")) + else: + seen_ids.add(entry_id) + + rel_path = str(entry["path"]).replace("\\", "/") + manifest_paths.add(rel_path) + + if not rel_path.startswith("UnitTests/testdata/interaction-traces/"): + violations.append((rel_path, "path must live under the interaction-traces corpus")) + continue + + absolute = root / rel_path + if not absolute.is_file(): + violations.append((rel_path, "manifest path does not exist")) + continue + + digest = entry["sha256"] + if not isinstance(digest, str) or not SHA256.match(digest): + violations.append((rel_path, "sha256 must be a 64-character lowercase hex digest")) + else: + actual = sha256_file(absolute) + if actual != digest: + violations.append( + (rel_path, f"sha256 mismatch (manifest {digest}, actual {actual})") + ) + + try: + scenario = json.loads(absolute.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + violations.append((rel_path, f"unable to load scenario: {exc}")) + continue + + if scenario.get("scenario_id") != entry_id: + violations.append( + (rel_path, f"scenario_id {scenario.get('scenario_id')!r} != manifest id {entry_id!r}") + ) + + violations.extend(validate_scenario(scenario, rel_path)) + + for path in sorted(corpus_dir.glob("*.json")): + if path.name == "manifest.json": + continue + rel_path = str(path.relative_to(root)).replace("\\", "/") + if rel_path not in manifest_paths: + violations.append((rel_path, "tracked scenario is missing from manifest.json")) + + return violations + + +def runnable_ids(manifest: dict) -> set[str]: + """Corpus ids whose harness support has landed. + + A blocked scenario is tracked and validated as data, but nothing can run it + yet, so demanding a run for it would report the corpus as broken rather + than as ahead of the harness. + """ + return { + str(entry["id"]) + for entry in manifest.get("scenarios", []) + if not entry.get("blocked_on") + } + + +def blocked_ids(manifest: dict) -> dict[str, str]: + """Blocked corpus ids mapped to what they are waiting on.""" + return { + str(entry["id"]): str(entry.get("blocked_on")) + for entry in manifest.get("scenarios", []) + if entry.get("blocked_on") + } + + +def _percentile_keys(block: dict) -> list[str]: + return [key for key in block if key.startswith("p") and key[1:].split("_")[0].isdigit()] + + +def validate_percentiles(block: object, label: str) -> list[Violation]: + """Enforce the no-zero-for-missing rule (#140 AC2, docs/RESOURCE_BUDGETS.md). + + An unavailable measurement must carry null percentiles; an available one + must carry real numbers. A zero standing in for a measurement that never + happened is the failure this exists to prevent. + """ + if not isinstance(block, dict): + return [(label, "percentile block must be an object")] + + violations: list[Violation] = [] + + if "available" not in block: + return [(label, "percentile block must carry 'available'")] + + available = block["available"] + if not isinstance(available, bool): + return [(label, "'available' must be a boolean")] + + keys = _percentile_keys(block) + if not keys: + return [(label, "percentile block must carry p50/p95/p99 values")] + + for key in keys: + value = block[key] + if available: + if not isinstance(value, (int, float)) or isinstance(value, bool): + violations.append((label, f"{key} must be a number when available is true")) + elif value is not None: + violations.append( + (label, f"{key} must be null when available is false, got {value!r}") + ) + + if not available and block.get("sample_count", 0) not in (0, None): + violations.append((label, "sample_count must be 0 when available is false")) + + return violations + + +def validate_report( + report: dict, + corpus_ids: set[str] | None = None, + expected_corpus_digest: str | None = None, + known_ids: set[str] | None = None, +) -> list[Violation]: + """Return (subject, reason) for every report violation.""" + violations: list[Violation] = [] + + if report.get("schema_kind") != "loop-interaction-trace-report": + violations.append(("report", "schema_kind must be loop-interaction-trace-report")) + if report.get("schema_version") != 1: + violations.append(("report", "schema_version must be 1")) + return violations + + lane = report.get("lane") + if lane not in LANES: + violations.append(("report", f"lane must be one of {list(LANES)}, got {lane!r}")) + + identity = report.get("identity") + if not isinstance(identity, dict): + violations.append(("report.identity", "identity must be an object")) + else: + for field in IDENTITY_FIELDS: + value = identity.get(field) + if not isinstance(value, str) or not value.strip(): + violations.append(("report.identity", f"{field} must be a non-empty string")) + digest = identity.get("corpus_digest") + if ( + expected_corpus_digest + and isinstance(digest, str) + and digest != expected_corpus_digest + ): + violations.append( + ( + "report.identity", + f"corpus_digest {digest} does not match the tracked corpus {expected_corpus_digest}", + ) + ) + + runs = report.get("runs") + if not isinstance(runs, list) or not runs: + violations.append(("report.runs", "runs must be a non-empty array")) + return violations + + seen: set[str] = set() + + for index, run in enumerate(runs): + label = f"runs[{index}]" + if not isinstance(run, dict): + violations.append((label, "run must be an object")) + continue + + scenario_id = run.get("scenario_id") + if not isinstance(scenario_id, str) or not KEBAB_CASE.match(scenario_id or ""): + violations.append((label, f"scenario_id must be kebab-case, got {scenario_id!r}")) + elif scenario_id in seen: + violations.append((label, f"duplicate scenario_id {scenario_id!r}")) + else: + seen.add(scenario_id) + label = f"runs[{scenario_id}]" + + status = run.get("status") + if status not in STATUSES: + violations.append((label, f"status must be one of {list(STATUSES)}, got {status!r}")) + + passed = run.get("passed") + if not isinstance(passed, bool): + violations.append((label, "passed must be a boolean")) + passed = None + + contract = run.get("first_violated_contract") + phase = run.get("responsible_phase") + excerpt = run.get("failure_excerpt") + + if passed is False: + # AC7: a failure must name what broke and who is responsible. A + # red run with no attribution is the outcome this check exists for. + if contract not in CONTRACTS: + violations.append( + (label, f"failed run needs first_violated_contract in {list(CONTRACTS)}, got {contract!r}") + ) + if phase not in PHASES: + violations.append( + (label, f"failed run needs responsible_phase in {list(PHASES)}, got {phase!r}") + ) + if not isinstance(excerpt, list) or not excerpt: + violations.append((label, "failed run needs a non-empty failure_excerpt")) + elif passed is True: + if contract is not None: + violations.append((label, "passing run must not name a violated contract")) + if phase is not None: + violations.append((label, "passing run must not name a responsible phase")) + + for key in ("input_to_frame_ms", "frame_time_ms"): + if key in run: + violations.extend(validate_percentiles(run[key], f"{label}.{key}")) + + for key, block in (run.get("stage_ms") or {}).items(): + violations.extend(validate_percentiles(block, f"{label}.stage_ms.{key}")) + + hit_test = run.get("hit_test") or {} + for key, block in hit_test.items(): + violations.extend(validate_percentiles(block, f"{label}.hit_test.{key}")) + + present = run.get("present_timing") + if isinstance(present, dict): + if present.get("available") is False and not str(present.get("reason", "")).strip(): + violations.append( + (label, "present_timing must carry a reason when unavailable") + ) + elif present.get("available") is True: + violations.extend(validate_percentiles(present, f"{label}.present_timing")) + + # A verified status is a claim that the measurement happened. It may + # not be paired with telemetry that says it did not. + if status == "verified": + latency = run.get("input_to_frame_ms") + if isinstance(latency, dict) and latency.get("available") is False: + violations.append( + (label, "status is verified but input_to_frame_ms is unavailable") + ) + + if corpus_ids is not None: + # corpus_ids are the scenarios that must run; known_ids additionally + # covers blocked scenarios, which may run early but need not. + for missing_id in sorted(corpus_ids - seen): + violations.append(("report.runs", f"corpus scenario {missing_id!r} has no run")) + for extra_id in sorted(seen - (known_ids if known_ids is not None else corpus_ids)): + violations.append(("report.runs", f"run {extra_id!r} is not in the corpus")) + + return violations + + +def trend_rows(report: dict, baseline: dict | None) -> list[str]: + """Per-scenario p50/p95/p99 lines, with deltas when a baseline is given.""" + base_runs = {} + if isinstance(baseline, dict): + base_runs = { + run.get("scenario_id"): run + for run in baseline.get("runs", []) + if isinstance(run, dict) + } + + rows = [] + for run in report.get("runs", []): + if not isinstance(run, dict): + continue + scenario_id = run.get("scenario_id", "?") + latency = run.get("input_to_frame_ms") or {} + if not latency.get("available"): + rows.append(f"{scenario_id}: input-to-frame unavailable ({run.get('status')})") + continue + + line = ( + f"{scenario_id}: p50={latency.get('p50_ms')}ms " + f"p95={latency.get('p95_ms')}ms p99={latency.get('p99_ms')}ms" + ) + base_latency = (base_runs.get(scenario_id) or {}).get("input_to_frame_ms") or {} + if base_latency.get("available"): + try: + delta = float(latency["p95_ms"]) - float(base_latency["p95_ms"]) + line += f" (p95 delta {delta:+.3f}ms)" + except (TypeError, ValueError, KeyError): + pass + rows.append(line) + + return rows + + +def load_json(path: Path) -> dict: + with path.open(encoding="utf-8") as handle: + return json.load(handle) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("report", nargs="?", help="trace report emitted by the test binary") + parser.add_argument( + "--corpus-only", + action="store_true", + help="validate the scenario corpus and exit; requires no build", + ) + parser.add_argument("--baseline", help="baseline report for --trend deltas") + parser.add_argument( + "--trend", + action="store_true", + help="print per-scenario percentiles and deltas; never fails the run", + ) + args = parser.parse_args(argv) + + corpus_violations = validate_corpus() + if corpus_violations: + print("ERROR: interaction trace corpus failed validation:", file=sys.stderr) + for subject, reason in corpus_violations: + print(f" {subject}: {reason}", file=sys.stderr) + return 1 + + if args.corpus_only: + manifest = load_manifest() + blocked = blocked_ids(manifest) + print( + f"Interaction trace corpus policy passed " + f"({len(manifest['scenarios'])} scenarios, {len(blocked)} awaiting harness support)." + ) + for scenario_id, waiting_on in sorted(blocked.items()): + print(f" blocked: {scenario_id} (waiting on {waiting_on})") + return 0 + + if not args.report: + parser.error("a report path is required unless --corpus-only is given") + + report_path = Path(args.report) + if not report_path.is_absolute(): + report_path = Path.cwd() / report_path + + try: + report = load_json(report_path) + except (OSError, json.JSONDecodeError) as exc: + print(f"ERROR: unable to load {report_path}: {exc}", file=sys.stderr) + return 1 + + manifest = load_manifest() + required = runnable_ids(manifest) + known = required | set(blocked_ids(manifest)) + violations = validate_report(report, required, corpus_digest(manifest), known) + + if violations: + print("ERROR: interaction trace report failed validation:", file=sys.stderr) + for subject, reason in violations: + print(f" {subject}: {reason}", file=sys.stderr) + return 1 + + failed = [run for run in report["runs"] if run.get("passed") is False] + for run in failed: + print( + f"FAIL {run['scenario_id']}: {run['first_violated_contract']} " + f"(phase {run['responsible_phase']})", + file=sys.stderr, + ) + for line in run.get("failure_excerpt", []): + print(f" {line}", file=sys.stderr) + + if args.trend: + baseline = None + if args.baseline and Path(args.baseline).is_file(): + baseline = load_json(Path(args.baseline)) + print("Interaction trace trend:") + for row in trend_rows(report, baseline): + print(f" {row}") + + if failed: + return 1 + + print(f"Interaction trace report passed ({len(report['runs'])} scenarios, lane {report['lane']}).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/check_phase5_residue.py b/scripts/ci/check_phase5_residue.py index ee8e81e61..df43bf876 100644 --- a/scripts/ci/check_phase5_residue.py +++ b/scripts/ci/check_phase5_residue.py @@ -16,6 +16,9 @@ "WixInstaller/", "Desktop/", "README.md", + "AGENTS.md", + ".claude/", + ".cursor/", "LoopEditor/", "LoopLibCore/", "LoopLibInteraction/", diff --git a/scripts/ci/test_check_branch_policy.py b/scripts/ci/test_check_branch_policy.py index 077efa759..e531b4bce 100644 --- a/scripts/ci/test_check_branch_policy.py +++ b/scripts/ci/test_check_branch_policy.py @@ -17,7 +17,7 @@ ROOT = Path(__file__).resolve().parents[2] -EXPECTED_BRANCHES = ("dev", "stable") +EXPECTED_BRANCHES = ("dev", "unstable", "stable") class BranchPolicyTests(unittest.TestCase): @@ -33,13 +33,14 @@ def test_documented_policy_declares_ci_branches_and_required_check(self): self.assertEqual(required_check, "release_ok") self.assertEqual(policy.required_check, "release_ok") self.assertEqual(policy.required_check_app.lower(), "github actions") - self.assertEqual(policy.protected_branches, ("dev", "stable")) + self.assertEqual(policy.protected_branches, ("unstable", "stable")) + self.assertEqual(policy.promotion_chain, ("dev", "unstable", "stable")) self.assertEqual(policy.integration_required_check, "agent-fast / build") self.assertEqual(policy.release_gate_workflow, ".github/workflows/release-gate.yml") self.assertEqual(policy.release_gate_events, ("pull_request", "merge_group")) self.assertEqual(policy.release_gate_pull_request_branches, ("stable",)) self.assertEqual(policy.integration_workflow, ".github/workflows/ci.yml") - self.assertEqual(policy.integration_pull_request_branches, ("dev",)) + self.assertEqual(policy.integration_pull_request_branches, ("dev", "unstable")) def test_current_ci_workflow_matches_policy(self): policy = parse_documented_policy_full( @@ -47,7 +48,7 @@ def test_current_ci_workflow_matches_policy(self): ) workflow = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") self.assertEqual(parse_workflow_branch_triggers(workflow)["push"], EXPECTED_BRANCHES) - self.assertEqual(parse_workflow_branch_triggers(workflow)["pull_request"], ("dev",)) + self.assertEqual(parse_workflow_branch_triggers(workflow)["pull_request"], ("dev", "unstable")) self.assertEqual(validate_integration_workflow(Path("ci.yml"), workflow, policy), []) def test_current_release_gate_matches_policy(self): @@ -76,6 +77,7 @@ def test_rejects_deliberately_stale_master_trigger(self): pull_request: branches: - dev + - unstable - stable """ violations = validate_workflow_branches(Path("stale.yml"), stale_workflow, EXPECTED_BRANCHES) @@ -144,9 +146,9 @@ def test_rejects_obsolete_ci_ok_aggregate(self): ) stale = """on: push: - branches: [dev, stable] + branches: [dev, unstable, stable] pull_request: - branches: [dev] + branches: [dev, unstable] jobs: ci_ok: @@ -161,9 +163,9 @@ def test_rejects_manual_dispatch_without_full_platform_jobs(self): ) stale = """on: push: - branches: [dev, stable] + branches: [dev, unstable, stable] pull_request: - branches: [dev] + branches: [dev, unstable] workflow_dispatch: jobs: @@ -193,11 +195,12 @@ def test_live_protection_rejects_ci_ok_and_unbound_app(self): } violations = validate_live_protection( stable_protection=stale_stable, - dev_protection={ + unstable_protection={ "required_status_checks": { "checks": [{"context": "agent-fast / build", "app_id": GITHUB_ACTIONS_APP_ID}], } }, + dev_protection={}, policy=policy, ) self.assertTrue(any("ci_ok" in item for item in violations)) @@ -213,7 +216,7 @@ def test_live_protection_accepts_github_actions_release_ok(self): "checks": [{"context": "release_ok", "app_id": GITHUB_ACTIONS_APP_ID}], } } - dev = { + unstable = { "required_status_checks": { "checks": [{"context": "agent-fast / build", "app_id": GITHUB_ACTIONS_APP_ID}], } @@ -221,13 +224,14 @@ def test_live_protection_accepts_github_actions_release_ok(self): self.assertEqual( validate_live_protection( stable_protection=stable, - dev_protection=dev, + unstable_protection=unstable, + dev_protection={}, policy=policy, ), [], ) - def test_live_protection_rejects_mismatched_dev_checks(self): + def test_live_protection_rejects_mismatched_unstable_checks(self): policy = parse_documented_policy_full( (ROOT / "docs" / "BRANCH_POLICY.md").read_text(encoding="utf-8") ) @@ -236,17 +240,44 @@ def test_live_protection_rejects_mismatched_dev_checks(self): "checks": [{"context": "release_ok", "app_id": GITHUB_ACTIONS_APP_ID}], } } - dev = { + unstable = { + "required_status_checks": { + "checks": [{"context": "release_ok", "app_id": GITHUB_ACTIONS_APP_ID}], + } + } + violations = validate_live_protection( + stable_protection=stable, + unstable_protection=unstable, + dev_protection={}, + policy=policy, + ) + self.assertTrue(any("unstable required checks" in item for item in violations)) + + def test_live_protection_rejects_dev_checks(self): + policy = parse_documented_policy_full( + (ROOT / "docs" / "BRANCH_POLICY.md").read_text(encoding="utf-8") + ) + stable = { "required_status_checks": { "checks": [{"context": "release_ok", "app_id": GITHUB_ACTIONS_APP_ID}], } } + dev = { + "required_status_checks": { + "checks": [{"context": "agent-fast / build", "app_id": GITHUB_ACTIONS_APP_ID}], + } + } violations = validate_live_protection( stable_protection=stable, + unstable_protection={ + "required_status_checks": { + "checks": [{"context": "agent-fast / build", "app_id": GITHUB_ACTIONS_APP_ID}], + } + }, dev_protection=dev, policy=policy, ) - self.assertTrue(any("dev required checks" in item for item in violations)) + self.assertTrue(any("dev must not require status checks" in item for item in violations)) if __name__ == "__main__": diff --git a/scripts/ci/test_check_interaction_traces.py b/scripts/ci/test_check_interaction_traces.py new file mode 100644 index 000000000..428d73a16 --- /dev/null +++ b/scripts/ci/test_check_interaction_traces.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 +"""Tests for the interaction trace corpus and report checker.""" + +from __future__ import annotations + +import copy +import json +import pathlib +import sys +import tempfile +import unittest + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2])) + +from scripts.ci.check_interaction_traces import ( # noqa: E402 + blocked_ids, + corpus_digest, + load_manifest, + runnable_ids, + trend_rows, + validate_corpus, + validate_percentiles, + validate_report, + validate_scenario, +) + + +def minimal_scenario(**over) -> dict: + scenario = { + "schema_kind": "loop-interaction-scenario", + "schema_version": 1, + "scenario_id": "example", + "description": "An example scenario.", + "fixture": { + "page_count": 1, + "page_size_mm": {"width": 210.0, "height": 297.0}, + "pixel_per_mm": 2.0, + "device_pixel_ratio": 1.0, + "initial_zoom": 1.0, + "viewport_size_px": {"width": 800, "height": 600}, + "page_layout": "single-page", + }, + "cost_model": {"base_frame_ns": 500000}, + "budgets": { + "refresh_rate_hz": 60.0, + "frame_p95_ms": 16.667, + "input_to_frame_p95_ms": 16.667, + }, + "expected": {"selected_id": ""}, + "input_script": [{"kind": "pointer-move", "at_px": {"x": 1, "y": 1}}], + } + scenario.update(over) + return scenario + + +def duration(available=True, p50=1.0, p95=2.0, p99=3.0, sample_count=10) -> dict: + if not available: + return {"available": False, "sample_count": 0, "p50_ms": None, "p95_ms": None, "p99_ms": None} + return { + "available": True, + "sample_count": sample_count, + "p50_ms": p50, + "p95_ms": p95, + "p99_ms": p99, + } + + +def minimal_report(**over) -> dict: + report = { + "schema_kind": "loop-interaction-trace-report", + "schema_version": 1, + "lane": "deterministic", + "identity": { + "commit": "abc123", + "compiler": "GNU 13.2", + "os": "Linux", + "qt": "6.11.1", + "cpu": "x86_64", + "renderer": "software", + "fixture_digest": "deadbeef", + "profile_or_operation_version": "interaction-trace-summary/2", + "corpus_digest": "0" * 64, + }, + "runs": [ + { + "scenario_id": "example", + "status": "verified", + "trace_id": "example", + "summary_schema_version": 2, + "budgets": {}, + "samples": {}, + "input_to_frame_ms": duration(), + "frame_time_ms": duration(), + "stage_ms": {}, + "slow_frame_causes": {}, + "hit_test": {}, + "async_overlap": {}, + "page_surface_cache": {"hits": 1, "misses": 0}, + "present_timing": { + "available": False, + "reason": "interaction-trace/present-timing-unavailable", + }, + "passed": True, + "first_violated_contract": None, + "responsible_phase": None, + "failure_excerpt": [], + } + ], + } + report.update(over) + return report + + +class CorpusTests(unittest.TestCase): + def test_repository_corpus_passes(self): + self.assertEqual(validate_corpus(), []) + + def test_every_manifest_scenario_exists_and_matches(self): + manifest = load_manifest() + self.assertTrue(manifest["scenarios"]) + for entry in manifest["scenarios"]: + self.assertEqual(entry["issue"], 146) + + def test_corpus_digest_is_order_independent(self): + manifest = load_manifest() + reversed_manifest = { + **manifest, + "scenarios": list(reversed(manifest["scenarios"])), + } + self.assertEqual(corpus_digest(manifest), corpus_digest(reversed_manifest)) + + def test_detects_digest_mismatch(self): + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + corpus = root / "UnitTests" / "testdata" / "interaction-traces" + corpus.mkdir(parents=True) + (corpus / "example.json").write_text( + json.dumps(minimal_scenario()), encoding="utf-8" + ) + (corpus / "manifest.json").write_text( + json.dumps( + { + "schema_kind": "loop-interaction-corpus", + "schema_version": 1, + "scenarios": [ + { + "id": "example", + "path": "UnitTests/testdata/interaction-traces/example.json", + "issue": 146, + "sha256": "f" * 64, + } + ], + } + ), + encoding="utf-8", + ) + violations = validate_corpus(corpus, root) + self.assertTrue(any("sha256 mismatch" in reason for _, reason in violations)) + + def test_digest_survives_a_crlf_checkout(self): + """.gitattributes checks text out as CRLF; the digests must not care. + + Without this the corpus gate passes on the machine that wrote the + manifest and fails on every fresh CI checkout. + """ + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + corpus = root / "UnitTests" / "testdata" / "interaction-traces" + corpus.mkdir(parents=True) + + source = pathlib.Path(__file__).resolve().parents[2] / "UnitTests" / "testdata" / "interaction-traces" + for path in source.glob("*.json"): + (corpus / path.name).write_bytes( + path.read_bytes().replace(b"\r\n", b"\n").replace(b"\n", b"\r\n") + ) + + self.assertEqual(validate_corpus(corpus, root), []) + + def test_untracked_scenario_is_reported(self): + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + corpus = root / "UnitTests" / "testdata" / "interaction-traces" + corpus.mkdir(parents=True) + (corpus / "stray.json").write_text(json.dumps(minimal_scenario()), encoding="utf-8") + (corpus / "manifest.json").write_text( + json.dumps( + { + "schema_kind": "loop-interaction-corpus", + "schema_version": 1, + "scenarios": [ + { + "id": "example", + "path": "UnitTests/testdata/interaction-traces/example.json", + "issue": 146, + "sha256": "f" * 64, + } + ], + } + ), + encoding="utf-8", + ) + violations = validate_corpus(corpus, root) + self.assertTrue(any("missing from manifest" in reason for _, reason in violations)) + + +class BlockedScenarioTests(unittest.TestCase): + """A scenario may be reviewed as data before the harness can run it.""" + + def test_runnable_and_blocked_partition_the_corpus(self): + manifest = load_manifest() + every_id = {str(entry["id"]) for entry in manifest["scenarios"]} + self.assertEqual(runnable_ids(manifest) | set(blocked_ids(manifest)), every_id) + self.assertEqual(runnable_ids(manifest) & set(blocked_ids(manifest)), set()) + + def test_blocked_scenario_needs_a_reason(self): + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + corpus = root / "UnitTests" / "testdata" / "interaction-traces" + corpus.mkdir(parents=True) + scenario = minimal_scenario() + body = json.dumps(scenario) + (corpus / "example.json").write_text(body, encoding="utf-8") + import hashlib + + digest = hashlib.sha256(body.encode("utf-8")).hexdigest() + (corpus / "manifest.json").write_text( + json.dumps( + { + "schema_kind": "loop-interaction-corpus", + "schema_version": 1, + "scenarios": [ + { + "id": "example", + "path": "UnitTests/testdata/interaction-traces/example.json", + "issue": 146, + "sha256": digest, + "blocked_on": "gh-488", + } + ], + } + ), + encoding="utf-8", + ) + violations = validate_corpus(corpus, root) + self.assertTrue(any("blocked_reason" in reason for _, reason in violations)) + + def test_blocked_scenario_does_not_need_a_run(self): + # The coverage check stays strict for everything else. + violations = validate_report( + minimal_report(), + corpus_ids={"example"}, + known_ids={"example", "drag-snap"}, + ) + self.assertEqual(violations, []) + + def test_blocked_scenario_may_still_report_a_run(self): + report = minimal_report() + report["runs"][0]["scenario_id"] = "drag-snap" + violations = validate_report( + report, corpus_ids=set(), known_ids={"example", "drag-snap"} + ) + self.assertEqual(violations, []) + + +class ScenarioTests(unittest.TestCase): + def test_minimal_scenario_passes(self): + self.assertEqual(validate_scenario(minimal_scenario(), "example"), []) + + def test_rejects_both_trace_and_script(self): + scenario = minimal_scenario(trace={"schema_version": 1, "inputs": [{}]}) + violations = validate_scenario(scenario, "example") + self.assertTrue(any("exactly one of" in reason for _, reason in violations)) + + def test_rejects_neither_trace_nor_script(self): + scenario = minimal_scenario() + del scenario["input_script"] + violations = validate_scenario(scenario, "example") + self.assertTrue(any("exactly one of" in reason for _, reason in violations)) + + def test_rejects_duplicate_hit_target_ids(self): + scenario = minimal_scenario() + bounds = {"x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0} + scenario["fixture"]["hit_targets"] = [ + {"kind": "finding", "page_index": 0, "id": "f-1", "page_bounds": bounds}, + {"kind": "finding", "page_index": 0, "id": "f-1", "page_bounds": bounds}, + ] + violations = validate_scenario(scenario, "example") + self.assertTrue(any("duplicate hit target" in reason for _, reason in violations)) + + def test_rejects_non_kebab_scenario_id(self): + violations = validate_scenario(minimal_scenario(scenario_id="Example_One"), "example") + self.assertTrue(any("kebab-case" in reason for _, reason in violations)) + + def test_rejects_fractional_cost(self): + scenario = minimal_scenario() + scenario["cost_model"]["hit_test_ns_per_candidate"] = 1.5 + violations = validate_scenario(scenario, "example") + self.assertTrue(any("nanoseconds" in reason for _, reason in violations)) + + +class PercentileTests(unittest.TestCase): + """The no-zero-for-missing rule from docs/RESOURCE_BUDGETS.md.""" + + def test_available_block_passes(self): + self.assertEqual(validate_percentiles(duration(), "latency"), []) + + def test_unavailable_block_passes_with_nulls(self): + self.assertEqual(validate_percentiles(duration(available=False), "latency"), []) + + def test_zero_standing_in_for_missing_is_rejected(self): + block = {"available": False, "sample_count": 0, "p50_ms": 0.0, "p95_ms": 0.0, "p99_ms": 0.0} + violations = validate_percentiles(block, "latency") + self.assertTrue(any("must be null" in reason for _, reason in violations)) + + def test_available_block_with_nulls_is_rejected(self): + block = {"available": True, "sample_count": 4, "p50_ms": None, "p95_ms": None, "p99_ms": None} + violations = validate_percentiles(block, "latency") + self.assertTrue(any("must be a number" in reason for _, reason in violations)) + + def test_unavailable_block_may_not_claim_samples(self): + block = {"available": False, "sample_count": 7, "p50_ms": None, "p95_ms": None, "p99_ms": None} + violations = validate_percentiles(block, "latency") + self.assertTrue(any("sample_count must be 0" in reason for _, reason in violations)) + + +class ReportTests(unittest.TestCase): + def test_minimal_report_passes(self): + self.assertEqual(validate_report(minimal_report()), []) + + def test_failed_run_must_name_contract_and_phase(self): + report = minimal_report() + report["runs"][0].update(passed=False, failure_excerpt=[]) + violations = validate_report(report) + self.assertTrue(any("first_violated_contract" in reason for _, reason in violations)) + self.assertTrue(any("responsible_phase" in reason for _, reason in violations)) + self.assertTrue(any("failure_excerpt" in reason for _, reason in violations)) + + def test_failed_run_with_attribution_passes(self): + report = minimal_report() + report["runs"][0].update( + passed=False, + first_violated_contract="p95-input-to-frame", + responsible_phase="overlay", + failure_excerpt=["p95 input-to-frame 24.10 ms exceeds 16.67 ms"], + ) + self.assertEqual(validate_report(report), []) + + def test_rejects_unknown_contract(self): + report = minimal_report() + report["runs"][0].update( + passed=False, + first_violated_contract="vibes", + responsible_phase="overlay", + failure_excerpt=["something"], + ) + violations = validate_report(report) + self.assertTrue(any("first_violated_contract" in reason for _, reason in violations)) + + def test_passing_run_may_not_name_a_violation(self): + report = minimal_report() + report["runs"][0]["first_violated_contract"] = "final-state" + violations = validate_report(report) + self.assertTrue(any("must not name a violated contract" in reason for _, reason in violations)) + + def test_verified_status_requires_available_latency(self): + report = minimal_report() + report["runs"][0]["input_to_frame_ms"] = duration(available=False) + violations = validate_report(report) + self.assertTrue(any("verified but" in reason for _, reason in violations)) + + def test_static_only_run_may_report_unavailable_latency(self): + report = minimal_report() + report["runs"][0]["status"] = "static-only" + report["runs"][0]["input_to_frame_ms"] = duration(available=False) + self.assertEqual(validate_report(report), []) + + def test_present_timing_unavailable_needs_a_reason(self): + report = minimal_report() + report["runs"][0]["present_timing"] = {"available": False, "reason": " "} + violations = validate_report(report) + self.assertTrue(any("must carry a reason" in reason for _, reason in violations)) + + def test_missing_corpus_scenario_is_reported(self): + violations = validate_report(minimal_report(), corpus_ids={"example", "pan"}) + self.assertTrue(any("has no run" in reason for _, reason in violations)) + + def test_unknown_run_is_reported(self): + violations = validate_report(minimal_report(), corpus_ids=set()) + self.assertTrue(any("is not in the corpus" in reason for _, reason in violations)) + + def test_duplicate_run_is_reported(self): + report = minimal_report() + report["runs"].append(copy.deepcopy(report["runs"][0])) + violations = validate_report(report) + self.assertTrue(any("duplicate scenario_id" in reason for _, reason in violations)) + + def test_corpus_digest_mismatch_is_reported(self): + violations = validate_report(minimal_report(), expected_corpus_digest="a" * 64) + self.assertTrue(any("does not match the tracked corpus" in reason for _, reason in violations)) + + def test_empty_identity_field_is_reported(self): + report = minimal_report() + report["identity"]["commit"] = " " + violations = validate_report(report) + self.assertTrue(any("commit must be a non-empty string" in reason for _, reason in violations)) + + +class TrendTests(unittest.TestCase): + def test_trend_reports_delta_against_baseline(self): + report = minimal_report() + baseline = minimal_report() + baseline["runs"][0]["input_to_frame_ms"] = duration(p95=1.5) + rows = trend_rows(report, baseline) + self.assertTrue(any("p95 delta +0.500ms" in row for row in rows)) + + def test_trend_says_unavailable_rather_than_zero(self): + report = minimal_report() + report["runs"][0]["status"] = "static-only" + report["runs"][0]["input_to_frame_ms"] = duration(available=False) + rows = trend_rows(report, None) + self.assertTrue(any("unavailable" in row for row in rows)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/test_check_phase5_residue.py b/scripts/ci/test_check_phase5_residue.py index 1d51e8950..0b84a2fda 100644 --- a/scripts/ci/test_check_phase5_residue.py +++ b/scripts/ci/test_check_phase5_residue.py @@ -39,6 +39,30 @@ def test_current_docs_are_scanned(self) -> None: findings = check_phase5_residue.violations(root) self.assertEqual(len(findings), 1) + def test_generated_agent_adapters_are_scanned(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "AGENTS.md").write_text("LoopLibGui is gone.\n", encoding="utf-8") + with mock.patch.object(check_phase5_residue, "tracked_paths", return_value=["AGENTS.md"]): + findings = check_phase5_residue.violations(root) + self.assertEqual(len(findings), 1) + + def test_validation_scripts_are_excluded(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / "scripts" + path.mkdir() + (path / "verify-installed-product-graph.py").write_text( + "FORBIDDEN = 'LoopViewer'\n", encoding="utf-8" + ) + with mock.patch.object( + check_phase5_residue, + "tracked_paths", + return_value=["scripts/verify-installed-product-graph.py"], + ): + findings = check_phase5_residue.violations(root) + self.assertEqual(findings, []) + if __name__ == "__main__": unittest.main() diff --git a/scripts/ci/test_verify_phase5_widgets_contract.py b/scripts/ci/test_verify_phase5_widgets_contract.py index e8c1b5b27..2b700afac 100644 --- a/scripts/ci/test_verify_phase5_widgets_contract.py +++ b/scripts/ci/test_verify_phase5_widgets_contract.py @@ -31,7 +31,7 @@ def setUpClass(cls): def test_current_evidence_is_valid_and_complete(self): self.assertEqual(validate_contract(ROOT, self.inventory, self.disposition), []) - self.assertEqual(self.inventory["counts"]["targets"], 68) + self.assertEqual(self.inventory["counts"]["targets"], 70) self.assertEqual(self.inventory["counts"]["widgets_surfaces"], 4) self.assertEqual(self.inventory["counts"]["ui_forms"], 2) self.assertEqual(len(self.inventory["plugin_ui"]), 0) diff --git a/scripts/ci/test_workflow_contracts.py b/scripts/ci/test_workflow_contracts.py index 69725af39..ef119bf08 100644 --- a/scripts/ci/test_workflow_contracts.py +++ b/scripts/ci/test_workflow_contracts.py @@ -67,6 +67,35 @@ def test_windows_release_gate_qualifies_without_widgets(self): def test_package_workflows_require_and_record_exact_source_sha(self): linux = (ROOT / ".github/workflows/LinuxInstall.yml").read_text(encoding="utf-8") windows = (ROOT / ".github/workflows/WindowsInstall.yml").read_text(encoding="utf-8") + self.assertIn("./vcpkg/vcpkg integrate install", linux) + self.assertNotIn("./vcpkg integrate install", linux) + self.assertIn("libfontconfig1-dev", linux) + self.assertIn("runs-on: ubuntu-22.04", linux) + self.assertIn("VCPKG_DEFAULT_BINARY_CACHE", linux) + self.assertIn("VCPKG_BINARY_SOURCES=clear;files", linux) + self.assertIn("./vcpkg-binary-cache", linux) + self.assertIn("cmake --build build --target LoopEditor PdfTool ProductQuickAccessibilitySmoke release_translations -j6", linux) + self.assertNotIn("--target all", linux) + self.assertNotIn("ctest --test-dir build", linux) + self.assertIn("Deploy Qt runtime closure to staged install tree", windows) + self.assertIn("windeployqt.exe", windows) + self.assertIn("--no-compiler-runtime", windows) + self.assertIn("--qmldir", windows) + self.assertIn("windeployqt-$name.txt", windows) + self.assertIn("LoopEditor.exe", windows) + self.assertIn("Qml2Imports=qml", windows) + self.assertIn('Join-Path $installBin "qt.conf"', windows) + self.assertIn("build\\LoopEditor\\Loop\\Quick", windows) + self.assertIn("plugins/sqldrivers", linux) + self.assertIn("build/LoopEditor/Loop/Quick", linux) + self.assertIn("VCPKG_BINARY_SOURCES=clear;files", windows) + self.assertIn("./vcpkg_installed", windows) + self.assertIn("./vcpkg-binary-cache", windows) + self.assertIn("cmake --build build --target LoopEditor PdfTool ProductQuickAccessibilitySmoke release_translations --config Release -j6", windows) + self.assertNotIn("--target all", windows) + self.assertNotIn("ctest --test-dir build", windows) + self.assertIn("--appimage-extract-and-run", linux) + self.assertIn("linuxdeployqt.txt", linux) for workflow in (linux, windows): self.assertIn("source_sha:", workflow) self.assertRegex(workflow, r"source_sha:\n\s+description:.*\n\s+required:\s+true") diff --git a/scripts/generate-architecture-catalogs.py b/scripts/generate-architecture-catalogs.py index add9b7ced..a3ca9220b 100644 --- a/scripts/generate-architecture-catalogs.py +++ b/scripts/generate-architecture-catalogs.py @@ -46,8 +46,10 @@ def parse_branch_policy() -> dict[str, Any]: "default_branch", "release_branch", "integration_branch", + "qualification_branch", "topic_branch_source", "topic_branch_patterns", + "promotion_chain", "protected_branches", } missing = sorted(required - policy.keys()) @@ -57,8 +59,10 @@ def parse_branch_policy() -> dict[str, Any]: policy["default_branch"], policy["release_branch"], policy["integration_branch"], + policy["qualification_branch"], policy["topic_branch_source"], *policy["protected_branches"], + *policy["promotion_chain"], } if any(not isinstance(branch, str) or not branch for branch in branches): raise ValueError("branch policy contains an empty branch name") @@ -70,7 +74,9 @@ def parse_branch_policy() -> dict[str, Any]: "default": policy["default_branch"], "release": policy["release_branch"], "integration": policy["integration_branch"], + "qualification": policy["qualification_branch"], "topic_source": policy["topic_branch_source"], + "promotion_chain": policy["promotion_chain"], "protected": sorted(policy["protected_branches"]), "topic_patterns": sorted(policy["topic_branch_patterns"]), } diff --git a/scripts/hooks/pre-push.sh b/scripts/hooks/pre-push.sh index 660f0b58c..8770eb53c 100644 --- a/scripts/hooks/pre-push.sh +++ b/scripts/hooks/pre-push.sh @@ -2,7 +2,7 @@ # BSP-002 §3.3, §3.4, §3.2; BSP-006 §3.4, §5.1 — pre-push policy set -euo pipefail -protected="${IVORY_PROTECTED_BRANCHES:-^refs/heads/(main|master|stable|dev|release/.*)$}" +protected="${IVORY_PROTECTED_BRANCHES:-^refs/heads/(main|master|stable|unstable|dev|release/.*)$}" while read -r local_ref local_sha remote_ref remote_sha; do [[ -z "$remote_ref" ]] && continue diff --git a/scripts/resource_envelope/budget_exhaustion_corpus.py b/scripts/resource_envelope/budget_exhaustion_corpus.py new file mode 100644 index 000000000..b174715c3 --- /dev/null +++ b/scripts/resource_envelope/budget_exhaustion_corpus.py @@ -0,0 +1,371 @@ +"""Synthetic adversarial PDF corpus for pdf::PDFProcessingBudget (gh-243). + +Each fixture is a small, deterministic, hand-assembled PDF shaped to trip +exactly one `pdf::PDFBudgetKind` when read with the tightened limit recorded +for it in manifest.json -- not by being large, but by being the wrong shape +(a decompression bomb, a deeply nested object, thousands of tiny operators, +...). None of these files are third-party samples and none exceed a few +kilobytes: the point is that a hostile document does not need to be big to +be hostile, and Loop must fail closed (report the exact exceeded budget) +rather than hang, get OOM-killed, or silently return a clean result. + +Two thirds of the manifest ("path": "session") trip their budget while +`pdf::PreflightEngine` walks an already-parsed document: the fixture is a +syntactically valid, fully readable PDF, and the corpus test tightens one +`pdf::PDFProcessingLimits` field (or, for the raster-probe fixture, one +preflight check parameter) before running a profile that touches page +content. The rest ("path": "reader") trip during `pdf::PDFDocumentReader` +itself, before a document exists at all -- those fixtures still parse as a +syntactically valid xref/trailer, but one object in the table is shaped to +blow the tightened limit while `pdf::PDFDocumentReader::readFromBuffer()` +walks every occupied entry. + +Regenerate with: + python3 -m scripts.resource_envelope.budget_exhaustion_corpus \ + --output-dir UnitTests/testdata/budget_exhaustion +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import zlib +from pathlib import Path + +SCHEMA_VERSION = 1 + + +def _object_body(number: int, body: bytes) -> bytes: + return f"{number} 0 obj\n".encode("ascii") + body + b"\nendobj\n" + + +def stream_body(extra_dict_entries: bytes, data: bytes) -> bytes: + """A stream object body: `<< /Length N >>\\nstream\\n\\nendstream`.""" + + prefix = b"<< " + extra_dict_entries + if extra_dict_entries: + prefix += b" " + return prefix + f"/Length {len(data)} >>\nstream\n".encode("ascii") + data + b"\nendstream" + + +def assemble_pdf(bodies: dict[int, bytes]) -> bytes: + """Assembles a minimal, syntactically valid PDF from object bodies. + + `bodies` maps an object number to its raw body (without the surrounding + "N 0 obj" / "endobj" markers). Object numbers must be the contiguous + range 1..max(bodies) so the cross-reference table is trivial to build, + and every entry -- reachable from the catalog or not -- lands in the + xref table, because pdf::PDFDocumentReader budgets every occupied entry + it walks, not only the ones the page tree references. + """ + + max_object = max(bodies) + if set(bodies) != set(range(1, max_object + 1)): + raise ValueError("object numbers must be contiguous starting at 1") + + pdf = bytearray(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n") + offsets = [0] * (max_object + 1) + for number in range(1, max_object + 1): + offsets[number] = len(pdf) + pdf.extend(_object_body(number, bodies[number])) + + xref_offset = len(pdf) + pdf.extend(f"xref\n0 {max_object + 1}\n".encode("ascii")) + pdf.extend(b"0000000000 65535 f \n") + for number in range(1, max_object + 1): + pdf.extend(f"{offsets[number]:010d} 00000 n \n".encode("ascii")) + pdf.extend( + f"trailer\n<< /Size {max_object + 1} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n".encode("ascii") + ) + return bytes(pdf) + + +def build_page_document(pages_content: list[bytes], media_box: tuple[int, int, int, int] = (0, 0, 612, 792)) -> dict[int, bytes]: + """A minimal, fully readable N-page document; object 1 is the Catalog, object 2 the Pages node. + + Pages use only resource-free content operators (rg/re/f and similar), so + no /Resources entries are required. Returns the object body map; caller + assembles it (optionally after appending more objects). + """ + + bodies: dict[int, bytes] = {1: b"<< /Type /Catalog /Pages 2 0 R >>"} + media = " ".join(str(value) for value in media_box) + page_refs: list[int] = [] + next_object = 3 + for content in pages_content: + page_object = next_object + content_object = next_object + 1 + next_object += 2 + page_refs.append(page_object) + bodies[page_object] = ( + f"<< /Type /Page /Parent 2 0 R /MediaBox [{media}]" + f" /Resources << /ProcSet [/PDF] >> /Contents {content_object} 0 R >>" + ).encode("ascii") + bodies[content_object] = stream_body(b"", content) + kids = " ".join(f"{reference} 0 R" for reference in page_refs) + bodies[2] = f"<< /Type /Pages /Kids [{kids}] /Count {len(page_refs)} >>".encode("ascii") + return bodies + + +def _sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +# --------------------------------------------------------------------------- +# Fixture builders. Each returns (pdf_bytes, manifest_case_without_pdf_or_sha). +# --------------------------------------------------------------------------- + + +def build_decompression_bomb() -> tuple[bytes, dict]: + """FlateDecode content stream with an extreme decoded/compressed ratio.""" + + payload = b"A" * 200_000 + compressed = zlib.compress(payload, level=9) + content_stream = stream_body(b"/Filter /FlateDecode", compressed) + bodies = build_page_document([b"0 0 0 rg 0 0 1 1 re f\n"]) + # Splice a compressed content stream into the page built by build_page_document + # (object 4 is the first page's content stream; see build_page_document). + bodies[4] = content_stream + pdf = assemble_pdf(bodies) + case = { + "id": "decompression-bomb", + "description": ( + "A single page whose content stream is a FlateDecode decompression " + "bomb: %d bytes of highly compressible payload compress to %d bytes " + "(ratio ~%d:1)." % (len(payload), len(compressed), len(payload) // max(1, len(compressed))) + ), + "path": "session", + "limits": {"maxDecompressionRatio": 40}, + "profile": { + "name": "budget-corpus-decompression-bomb", + "checks": [{"id": "color-inventory", "severity": "info"}], + }, + "expected": {"kind": "decompression-ratio", "pool": "decoded-streams"}, + } + return pdf, case + + +def build_cumulative_decoded_bytes() -> tuple[bytes, dict]: + """Many pages, each with a small unfiltered content stream; the *sum* exceeds the cap.""" + + page_count = 12 + single_page_content = (b"1 0 0 rg 0 0 1 1 re f\n" * 46) # ~1012 bytes, decoded 1:1 (no filter) + bodies = build_page_document([single_page_content] * page_count) + pdf = assemble_pdf(bodies) + case = { + "id": "cumulative-decoded-bytes", + "description": ( + "%d pages, each with an unfiltered ~%d byte content stream; no single " + "stream is large, but the cumulative decoded total across the " + "document exceeds a tightened cap." % (page_count, len(single_page_content)) + ), + "path": "session", + "limits": {"maxCumulativeDecodedBytes": 6000}, + "profile": { + "name": "budget-corpus-cumulative-decoded-bytes", + "checks": [{"id": "color-inventory", "severity": "info"}], + }, + "expected": {"kind": "cumulative-decoded-bytes", "pool": "decoded-streams"}, + } + return pdf, case + + +def build_deep_nested_content_streams() -> tuple[bytes, dict]: + """A chain of Form XObjects, each invoking the next via the Do operator.""" + + form_count = 12 + bodies: dict[int, bytes] = {1: b"<< /Type /Catalog /Pages 2 0 R >>"} + + # Object numbers: 3 = page, 4 = page content, 5.. = form dictionaries (one + # object per form; each form's content stream is embedded via the form's + # own /Length, so a form is itself the stream object). + first_form_object = 5 + form_objects = [first_form_object + index for index in range(form_count)] + + page_object = 3 + page_content_object = 4 + bodies[page_object] = ( + f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200]" + f" /Resources << /ProcSet [/PDF] /XObject << /Fm0 {form_objects[0]} 0 R >> >>" + f" /Contents {page_content_object} 0 R >>" + ).encode("ascii") + bodies[page_content_object] = stream_body(b"", b"/Fm0 Do\n") + + for index, form_object in enumerate(form_objects): + is_last = index == len(form_objects) - 1 + if is_last: + resources = b"<< /ProcSet [/PDF] >>" + content = b"0 0 0 rg 0 0 1 1 re f\n" + else: + next_object = form_objects[index + 1] + resources = f"<< /ProcSet [/PDF] /XObject << /Fm{index + 1} {next_object} 0 R >> >>".encode("ascii") + content = f"/Fm{index + 1} Do\n".encode("ascii") + dict_entries = ( + b"/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Resources " + resources + ) + bodies[form_object] = stream_body(dict_entries, content) + + bodies[2] = f"<< /Type /Pages /Kids [{page_object} 0 R] /Count 1 >>".encode("ascii") + pdf = assemble_pdf(bodies) + case = { + "id": "deep-nested-content-streams", + "description": ( + "A page whose content stream invokes a chain of %d nested Form " + "XObjects (each drawing the next via the Do operator)." % form_count + ), + "path": "session", + "limits": {"maxRecursiveContentDepth": 4}, + "profile": { + "name": "budget-corpus-deep-nested-content-streams", + "checks": [{"id": "color-inventory", "severity": "info"}], + }, + "expected": {"kind": "recursive-content-depth", "pool": "document-model"}, + } + return pdf, case + + +def build_long_running_render_work() -> tuple[bytes, dict]: + """A content stream with far more operator/operand tokens than the tightened cap.""" + + content = b"0 0 1 1 re f\n" * 40 # 5 tokens per repeat = 200 tokens + bodies = build_page_document([content]) + pdf = assemble_pdf(bodies) + case = { + "id": "long-running-render-work", + "description": ( + "A page content stream with 200 operator/operand tokens -- a stand-in " + "for a pathologically operation-heavy page that would otherwise take " + "an unbounded amount of processing to finish rendering." + ), + "path": "session", + "limits": {"maxRenderOperations": 40}, + "profile": { + "name": "budget-corpus-long-running-render-work", + "checks": [{"id": "color-inventory", "severity": "info"}], + }, + "expected": {"kind": "render-operations", "pool": "raster-tile"}, + } + return pdf, case + + +def build_raster_probe_pixel_budget() -> tuple[bytes, dict]: + """A page whose declared extent forces an oversized raster probe.""" + + media_box = (0, 0, 4000, 4000) + content = b"1 0 0 rg 0 0 4000 4000 re f\n" + bodies = build_page_document([content], media_box=media_box) + pdf = assemble_pdf(bodies) + case = { + "id": "raster-probe-pixel-budget", + "description": ( + "A page with a 4000x4000pt declared MediaBox and a single fill " + "spanning it; the 'thin-parts' check's raster probe is configured " + "with an unreachably small pixel budget." + ), + "path": "session", + "limits": {}, + "profile": { + "name": "budget-corpus-raster-probe-pixel-budget", + "checks": [ + { + "id": "thin-parts", + "severity": "info", + "min_effective_width_pt": 0.25, + "classes": ["thin-fill"], + "probe_dpi": 150, + "max_raster_pixels": 4, + } + ], + }, + "expected": {"kind": "render-pixels", "pool": "raster-tile"}, + } + return pdf, case + + +def build_deep_recursive_object_graph() -> tuple[bytes, dict]: + """A minimal document plus one object holding a deeply nested array literal.""" + + bodies = build_page_document([b"0 0 0 rg 0 0 1 1 re f\n"]) + extra_object = max(bodies) + 1 + depth = 40 + nested_array = (b"[" * depth) + b"0" + (b"]" * depth) + bodies[extra_object] = nested_array + pdf = assemble_pdf(bodies) + case = { + "id": "deep-recursive-object-graph", + "description": ( + "An otherwise-ordinary document with one extra indirect object whose " + "value is a %d-level nested array literal, unreachable from the " + "catalog -- every occupied xref entry is still parsed." % depth + ), + "path": "reader", + "limits": {"maxObjectDepth": 20}, + "expected": {"kind": "object-depth", "pool": "document-model"}, + } + return pdf, case + + +def build_pathological_object_count() -> tuple[bytes, dict]: + """A minimal document plus hundreds of trivial extra indirect objects.""" + + bodies = build_page_document([b"0 0 0 rg 0 0 1 1 re f\n"]) + next_object = max(bodies) + 1 + extra_object_count = 120 + for offset in range(extra_object_count): + bodies[next_object + offset] = b"null" + pdf = assemble_pdf(bodies) + case = { + "id": "pathological-object-count", + "description": ( + "An otherwise-ordinary document plus %d trivial extra indirect " + "objects (unreachable from the catalog) driving the document's " + "total visited-object count past a tightened cap." % extra_object_count + ), + "path": "reader", + "limits": {"maxObjectsVisited": 60}, + "expected": {"kind": "objects-visited", "pool": "document-model"}, + } + return pdf, case + + +BUILDERS = ( + build_decompression_bomb, + build_cumulative_decoded_bytes, + build_deep_nested_content_streams, + build_long_running_render_work, + build_raster_probe_pixel_budget, + build_deep_recursive_object_graph, + build_pathological_object_count, +) + + +def generate_corpus(output_dir: Path) -> dict: + output_dir.mkdir(parents=True, exist_ok=True) + cases = [] + for builder in BUILDERS: + pdf_bytes, case = builder() + filename = case["id"].replace("-", "_") + ".pdf" + (output_dir / filename).write_bytes(pdf_bytes) + case = dict(case) + case["pdf"] = filename + case["sha256"] = _sha256(pdf_bytes) + cases.append(case) + + manifest = {"schema_version": SCHEMA_VERSION, "cases": cases} + manifest_path = output_dir / "manifest.json" + manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return manifest + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", type=Path, default=Path("UnitTests/testdata/budget_exhaustion")) + args = parser.parse_args() + manifest = generate_corpus(args.output_dir) + print(json.dumps({"cases": [case["id"] for case in manifest["cases"]]}, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/resource_envelope/create_fixture_manifest.py b/scripts/resource_envelope/create_fixture_manifest.py new file mode 100644 index 000000000..b3b8de671 --- /dev/null +++ b/scripts/resource_envelope/create_fixture_manifest.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Create a provenance manifest for external resource-envelope fixtures.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Sequence + +from scripts.resource_envelope.run_matrix import FIXTURE_SPECS, _fixture_args, _sha256 + + +def create_manifest(fixtures: dict[str, Path], provenance: str) -> dict[str, object]: + records: list[dict[str, object]] = [] + for fixture_id, path in fixtures.items(): + if not path.is_file(): + raise ValueError(f"fixture not found: {fixture_id}: {path}") + record: dict[str, object] = { + "fixture_id": fixture_id, + "path": str(path.resolve()), + "sha256": _sha256(path), + "size_bytes": path.stat().st_size, + "provenance": provenance, + } + expected_page_count = FIXTURE_SPECS[fixture_id]["expected_page_count"] + if expected_page_count is not None: + record["page_count"] = expected_page_count + records.append(record) + return { + "schema_kind": "loop-resource-envelope-fixtures", + "schema_version": 1, + "fixtures": records, + } + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--fixture", action="append", default=[], metavar="NAME=PATH") + parser.add_argument("--provenance", required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args(argv) + try: + manifest = create_manifest(_fixture_args(args.fixture), args.provenance) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + except (OSError, ValueError) as exc: + print(f"resource-envelope manifest error: {exc}", file=sys.stderr) + return 2 + print(json.dumps({"output": str(args.output.resolve()), "fixtures": len(manifest["fixtures"])}, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/resource_envelope/run_matrix.py b/scripts/resource_envelope/run_matrix.py new file mode 100644 index 000000000..ae5eeab31 --- /dev/null +++ b/scripts/resource_envelope/run_matrix.py @@ -0,0 +1,507 @@ +#!/usr/bin/env python3 +"""Run and validate the resource-envelope fixture matrix. + +Large PDFs stay outside the repository. A qualification run should use a +manifest with exact fixture digests and sizes; the legacy ``--fixture`` form is +kept for exploratory runs and is intentionally not sufficient for ``--strict``. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import signal +import statistics +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence + +from scripts.resource_envelope.validate_envelope import validate_envelope + + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_BUDGETS = ROOT / "docs" / "RESOURCE_ENVELOPE_BUDGETS.json" +MATRIX_KIND = "loop-resource-envelope-matrix" +DEFAULT_RASTERIZERS = 8 + +# These names mirror issue #242. multi-gb is optional because platform +# addressability and available disk are environment-dependent. +FIXTURE_SPECS: dict[str, dict[str, Any]] = { + "office-2mb": {"required": True, "expected_page_count": None, "workload": None, "min_bytes": 1_500_000, "max_bytes": 2_500_000}, + "image-heavy-500mb": {"required": True, "expected_page_count": None, "workload": None, "min_bytes": 450_000_000, "max_bytes": 550_000_000}, + "multi-gb": {"required": False, "expected_page_count": None, "workload": None, "min_bytes": 1_000_000_000, "max_bytes": None}, + "ten-thousand-page": {"required": True, "expected_page_count": 10000, "workload": "div2k-image-heavy", "min_bytes": None, "max_bytes": None}, + "pathological-vector": {"required": True, "expected_page_count": 256, "workload": "pathological-vector", "min_bytes": None, "max_bytes": None}, + "transparency-spots": {"required": True, "expected_page_count": 256, "workload": None, "min_bytes": None, "max_bytes": None}, +} + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _extract_json(stdout: str) -> dict[str, Any] | None: + """Extract PdfTool's JSON object, tolerating diagnostic text on stdout.""" + decoder = json.JSONDecoder() + for index, character in enumerate(stdout): + if character != "{": + continue + try: + value, _ = decoder.raw_decode(stdout[index:]) + except json.JSONDecodeError: + continue + if isinstance(value, dict): + return value + return None + + +def _envelope_from_output(payload: Mapping[str, Any]) -> dict[str, Any] | None: + data = payload.get("data") + if isinstance(data, Mapping) and isinstance(data.get("workload_envelope"), Mapping): + return dict(data["workload_envelope"]) + if isinstance(payload.get("workload_envelope"), Mapping): + return dict(payload["workload_envelope"]) + return None + + +def _git_head() -> str: + try: + return subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + except (OSError, subprocess.CalledProcessError): + return "" + + +def _candidate_identity() -> dict[str, Any]: + local_sha = _git_head() + environment_sha = next((os.environ.get(key, "").strip() for key in ("GITHUB_SHA", "GIT_COMMIT") if os.environ.get(key, "").strip()), "") + return { + "candidate_sha": local_sha or environment_sha, + "source": "git-head" if local_sha else "environment-fallback", + "environment_sha": environment_sha, + "verified": bool(local_sha) and (not environment_sha or environment_sha == local_sha), + } + + +def _run_benchmark_process(command: list[str], timeout_seconds: float, cancel_after_seconds: float | None) -> subprocess.CompletedProcess[str]: + creationflags = 0 + popen_kwargs: dict[str, Any] = {} + if os.name == "nt": + creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + else: + popen_kwargs["start_new_session"] = True + process = subprocess.Popen( + command, + cwd=ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + creationflags=creationflags, + **popen_kwargs, + ) + started = time.monotonic() + if cancel_after_seconds is not None: + while process.poll() is None and time.monotonic() - started < cancel_after_seconds: + time.sleep(min(0.05, cancel_after_seconds - (time.monotonic() - started))) + if process.poll() is None: + if os.name == "nt": + process.send_signal(getattr(signal, "CTRL_BREAK_EVENT", signal.SIGTERM)) + else: + process.send_signal(signal.SIGINT) + try: + stdout, stderr = process.communicate(timeout=max(0.1, timeout_seconds - (time.monotonic() - started))) + except subprocess.TimeoutExpired: + process.kill() + stdout, stderr = process.communicate() + return subprocess.CompletedProcess(command, process.returncode, stdout, stderr) + + +def _empty_result(fixture_id: str, reason: str) -> dict[str, Any]: + return { + "fixture_id": fixture_id, + "status": "unavailable", + "reason": reason, + "result": None, + "runs": [], + "validation_errors": [], + "regressions": [], + } + + +def _baseline_records(path: Path | None) -> dict[str, Mapping[str, Any]]: + if path is None: + return {} + payload = json.loads(path.read_text(encoding="utf-8")) + records = payload.get("fixtures") + if not isinstance(records, list): + raise ValueError("baseline must contain a fixtures array") + return { + str(record["fixture_id"]): record + for record in records + if isinstance(record, Mapping) and "fixture_id" in record + } + + +def _regressions(current: Mapping[str, Any], baseline: Mapping[str, Any] | None, margin: float) -> list[str]: + if baseline is None: + return [] + baseline_result = baseline.get("result") + if not isinstance(baseline_result, Mapping): + return [] + if current.get("fixture_sha256") != baseline.get("fixture_sha256"): + return ["baseline fixture digest does not match current fixture"] + current_identity = current.get("identity") + baseline_identity = baseline.get("identity") + if isinstance(current_identity, Mapping) and isinstance(baseline_identity, Mapping): + for key in ("os", "qt", "compiler", "renderer"): + if current_identity.get(key) != baseline_identity.get(key): + return [f"baseline identity mismatch: {key}"] + errors: list[str] = [] + for field in ("rss_high_water_bytes", "elapsed_ms"): + value = current.get(field) + old_value = baseline_result.get(field) + if not isinstance(value, int) or value < 0 or not isinstance(old_value, int) or old_value <= 0: + continue + if value > old_value * margin: + errors.append(f"{field} {value} exceeds baseline {old_value} by margin {margin:g}") + return errors + + +def _fixture_metadata(fixture_id: str, fixture_path: Path, metadata: Mapping[str, Any] | None, require_provenance: bool) -> tuple[dict[str, Any], list[str]]: + spec = FIXTURE_SPECS[fixture_id] + size = fixture_path.stat().st_size + digest = _sha256(fixture_path) + details: dict[str, Any] = {"fixture_sha256": digest, "input_bytes": size} + errors: list[str] = [] + if metadata is None: + if require_provenance: + errors.append("fixture provenance manifest not supplied") + else: + expected_digest = metadata.get("sha256") + expected_size = metadata.get("size_bytes") + if expected_digest != digest: + errors.append("fixture SHA-256 does not match manifest") + if expected_size != size: + errors.append(f"fixture size {size} does not match manifest {expected_size}") + details["provenance"] = metadata.get("provenance", "") + details["manifest_sha256"] = expected_digest + minimum = spec.get("min_bytes") + maximum = spec.get("max_bytes") + if minimum is not None and size < minimum: + errors.append(f"fixture is smaller than {minimum} bytes for {fixture_id}") + if maximum is not None and size > maximum: + errors.append(f"fixture is larger than {maximum} bytes for {fixture_id}") + return details, errors + + +def _aggregate_envelopes(envelopes: list[Mapping[str, Any]]) -> tuple[dict[str, Any], dict[str, Any]]: + # Use the highest-RSS run as the safety representative and the median + # elapsed time. This keeps peak-memory validation conservative while + # reducing scheduler noise in timing comparisons. + representative = dict(max(envelopes, key=lambda item: item.get("rss_high_water_bytes", -1))) + elapsed = [item["elapsed_ms"] for item in envelopes if isinstance(item.get("elapsed_ms"), int) and item["elapsed_ms"] >= 0] + rss = [item["rss_high_water_bytes"] for item in envelopes if isinstance(item.get("rss_high_water_bytes"), int) and item["rss_high_water_bytes"] >= 0] + stats = { + "repetitions": len(envelopes), + "elapsed_ms": {"median": statistics.median(elapsed) if elapsed else -1, "min": min(elapsed) if elapsed else -1, "max": max(elapsed) if elapsed else -1}, + "rss_high_water_bytes": {"median": statistics.median(rss) if rss else -1, "min": min(rss) if rss else -1, "max": max(rss) if rss else -1}, + "unstable": bool(rss and statistics.median(rss) > 0 and max(rss) > statistics.median(rss) * 1.2), + } + if elapsed: + representative["elapsed_ms"] = int(statistics.median(elapsed)) + if rss: + representative["rss_high_water_bytes"] = max(rss) + return representative, stats + + +def run_fixture( + pdf_tool: Path, + fixture_id: str, + fixture_path: Path, + budgets: Mapping[str, Any], + timeout_seconds: float, + baseline: Mapping[str, Any] | None = None, + margin: float = 2.0, + runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, + metadata: Mapping[str, Any] | None = None, + repetitions: int = 1, + rasterizers: int = DEFAULT_RASTERIZERS, + require_provenance: bool = False, + cancel_after_seconds: float | None = None, + candidate_sha: str | None = None, +) -> dict[str, Any]: + if repetitions < 1 or rasterizers < 1: + raise ValueError("repetitions and rasterizers must be positive") + if candidate_sha is None: + candidate_sha = _candidate_identity()["candidate_sha"] + # Resolve relative paths before the child is launched with cwd=ROOT. + # Otherwise a relative fixture or tool path checked against the caller + # directory would be looked up again relative to ROOT in the child. + pdf_tool = Path(pdf_tool).resolve() + fixture_path = Path(fixture_path).resolve() + spec = FIXTURE_SPECS[fixture_id] + fixture_details, provenance_errors = _fixture_metadata(fixture_id, fixture_path, metadata, require_provenance) + # Pin rasterizers to a fixed value (8) so the same code and fixtures + # produce comparable RSS and elapsed time across hosts with different + # CPU counts. The value is recorded in the result profile. + command = [str(pdf_tool), "benchmark", str(fixture_path), "--render-hw-accel", "0", "--render-rasterizers", str(rasterizers), "--console-format", "json"] + record: dict[str, Any] = { + "fixture_id": fixture_id, + "path": str(fixture_path.resolve()), + "expected_page_count": metadata.get("page_count", spec["expected_page_count"]) if metadata else spec["expected_page_count"], + "workload": spec["workload"], + "profile": {"render_hw_accel": False, "render_rasterizers": rasterizers}, + "command": command, + **fixture_details, + } + if provenance_errors: + record.update({"status": "failed", "result": None, "runs": [], "validation_errors": provenance_errors, "regressions": []}) + return record + + runs: list[dict[str, Any]] = [] + envelopes: list[Mapping[str, Any]] = [] + for index in range(repetitions): + try: + if runner is subprocess.run and cancel_after_seconds is not None: + completed = _run_benchmark_process(command, timeout_seconds, cancel_after_seconds) + else: + completed = runner(command, cwd=ROOT, check=False, capture_output=True, text=True, timeout=timeout_seconds) + except subprocess.TimeoutExpired: + runs.append({"run": index + 1, "status": "unavailable", "reason": "benchmark-timeout", "process_exit_code": None}) + continue + except OSError as exc: + runs.append({"run": index + 1, "status": "unavailable", "reason": f"benchmark-launch-failed:{exc}", "process_exit_code": None}) + continue + payload = _extract_json(completed.stdout) + envelope = _envelope_from_output(payload) if payload else None + if envelope is None: + runs.append({"run": index + 1, "status": "unavailable", "reason": "benchmark-envelope-missing", "process_exit_code": completed.returncode, "stderr": completed.stderr[-2000:]}) + continue + envelopes.append(envelope) + runs.append({"run": index + 1, "status": "recorded", "process_exit_code": completed.returncode, "result": envelope}) + + if not envelopes: + record.update({"status": "unavailable", "result": None, "runs": runs, "validation_errors": [], "regressions": []}) + return record + + representative, stats = _aggregate_envelopes(envelopes) + validation_errors: list[str] = [] + for index, envelope in enumerate(envelopes, start=1): + for error in validate_envelope(envelope, budgets, spec["workload"]): + validation_errors.append(f"run {index}: {error}") + expected_page_count = record["expected_page_count"] + if expected_page_count is not None and envelope.get("page_count") != expected_page_count: + validation_errors.append(f"run {index}: page_count {envelope.get('page_count')} does not match expected {expected_page_count}") + rss = envelope.get("rss_high_water_bytes") + resident_limit = budgets.get("resource_budget", {}).get("resident_limit_bytes") + if isinstance(rss, int) and rss >= 0 and isinstance(resident_limit, int) and rss > resident_limit: + validation_errors.append(f"run {index}: RSS {rss} exceeds resident policy {resident_limit}") + identity = envelope.get("identity") if isinstance(envelope.get("identity"), Mapping) else {} + if identity.get("commit") != candidate_sha: + validation_errors.append(f"run {index}: identity.commit {identity.get('commit')!r} does not match candidate {candidate_sha!r}") + if identity.get("fixture_digest") != record.get("fixture_sha256"): + validation_errors.append(f"run {index}: identity.fixture_digest {identity.get('fixture_digest')!r} does not match input {record.get('fixture_sha256')!r}") + record["identity"] = representative.get("identity", {}) + record["result"] = representative + record["statistics"] = stats + record["runs"] = runs + unavailable_runs = [run for run in runs if run["status"] != "recorded"] + validation_errors.extend( + f"run {run['run']}: {run['reason']}" for run in unavailable_runs + ) + record["validation_errors"] = sorted(set(validation_errors)) + comparison = dict(representative) + comparison["fixture_sha256"] = record["fixture_sha256"] + comparison["identity"] = record["identity"] + record["regressions"] = _regressions(comparison, baseline, margin) + if cancel_after_seconds is not None: + if representative.get("status") != "cancelled": + validation_errors.append("cancellation probe did not produce a cancelled envelope") + if not isinstance(representative.get("cancellation_latency_ms"), int) or representative["cancellation_latency_ms"] < 0: + validation_errors.append("cancellation probe did not report cancellation latency") + record["cancellation_probe"] = {"requested_after_seconds": cancel_after_seconds} + record["validation_errors"] = sorted(set(validation_errors)) + hard_error_markers = ("does not match", "exceeds", "identity", "fixture SHA", "manifest") + hard_errors = [error for error in record["validation_errors"] if any(marker in error for marker in hard_error_markers)] + if record["regressions"] or hard_errors: + record["status"] = "failed" + elif record["validation_errors"] or representative.get("status") != "complete": + record["status"] = "flagged" + else: + record["status"] = "measured" + return record + + +def _load_fixture_manifest(path: Path) -> dict[str, dict[str, Any]]: + payload = json.loads(path.read_text(encoding="utf-8")) + if payload.get("schema_kind") != "loop-resource-envelope-fixtures" or payload.get("schema_version") != 1: + raise ValueError("fixture manifest schema_kind/schema_version is invalid") + records = payload.get("fixtures") + if not isinstance(records, list): + raise ValueError("fixture manifest must contain a fixtures array") + result: dict[str, dict[str, Any]] = {} + for record in records: + if not isinstance(record, dict) or record.get("fixture_id") not in FIXTURE_SPECS: + raise ValueError("fixture manifest contains an unknown fixture_id") + fixture_id = str(record["fixture_id"]) + if fixture_id in result: + raise ValueError(f"fixture manifest contains duplicate fixture_id: {fixture_id}") + if not isinstance(record.get("path"), str) or not record["path"]: + raise ValueError(f"fixture manifest path missing: {fixture_id}") + digest = record.get("sha256") + if not isinstance(digest, str) or len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest): + raise ValueError(f"fixture manifest sha256 missing: {fixture_id}") + if not isinstance(record.get("size_bytes"), int) or record["size_bytes"] < 1: + raise ValueError(f"fixture manifest size_bytes missing: {fixture_id}") + if not isinstance(record.get("provenance"), str) or not record["provenance"].strip(): + raise ValueError(f"fixture manifest provenance missing: {fixture_id}") + if "page_count" in record and (not isinstance(record["page_count"], int) or record["page_count"] < 1): + raise ValueError(f"fixture manifest page_count is invalid: {fixture_id}") + normalized = dict(record) + fixture_path = Path(str(record["path"])) + if not fixture_path.is_absolute(): + normalized["path"] = str((path.parent / fixture_path).resolve()) + result[fixture_id] = normalized + return result + + +def _fixture_args(values: Sequence[str]) -> dict[str, Path]: + fixtures: dict[str, Path] = {} + for value in values: + name, separator, path = value.partition("=") + if not separator or name not in FIXTURE_SPECS or not path: + raise ValueError(f"fixture must be NAME=PATH for one of: {', '.join(FIXTURE_SPECS)}") + if name in fixtures: + raise ValueError(f"fixture supplied more than once: {name}") + fixtures[name] = Path(path) + return fixtures + + +def run_matrix( + pdf_tool: Path, + fixtures: Mapping[str, Path | Mapping[str, Any]], + budgets: Mapping[str, Any], + timeout_seconds: float, + baseline: Mapping[str, Any] | Path | None = None, + margin: float = 2.0, + runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, + repetitions: int = 1, + rasterizers: int = DEFAULT_RASTERIZERS, + cancel_fixture: str | None = None, + cancel_after_seconds: float | None = None, +) -> dict[str, Any]: + pdf_tool = Path(pdf_tool).resolve() + baseline_by_fixture = _baseline_records(baseline) if isinstance(baseline, Path) else (baseline or {}) + identity = _candidate_identity() + candidate_sha = identity["candidate_sha"] + records: list[dict[str, Any]] = [] + for fixture_id, spec in FIXTURE_SPECS.items(): + supplied = fixtures.get(fixture_id) + if supplied is None: + record = _empty_result(fixture_id, "fixture-not-supplied" if spec["required"] else "fixture-not-supplied-optional") + record["required"] = spec["required"] + records.append(record) + continue + metadata = dict(supplied) if isinstance(supplied, Mapping) else None + fixture_path = Path(metadata["path"]) if metadata else Path(supplied) + fixture_path = fixture_path.resolve() + if not fixture_path.is_file(): + record = _empty_result(fixture_id, "fixture-not-found") + record["required"] = spec["required"] + records.append(record) + continue + record = run_fixture(pdf_tool, fixture_id, fixture_path, budgets, timeout_seconds, baseline_by_fixture.get(fixture_id), margin, runner, metadata, repetitions, rasterizers, bool(metadata), cancel_after_seconds if fixture_id == cancel_fixture else None, candidate_sha) + record["required"] = spec["required"] + result = record.get("result") + if isinstance(result, Mapping): + result_identity = result.get("identity") + if isinstance(result_identity, Mapping): + commit = result_identity.get("commit") + if commit != candidate_sha: + record["validation_errors"] = sorted(set(record.get("validation_errors", []) + ["PdfTool identity commit does not match checkout HEAD"])) + record["status"] = "failed" + expected_digest = record.get("fixture_sha256") + fixture_digest = result_identity.get("fixture_digest") + if expected_digest and fixture_digest != expected_digest: + record["validation_errors"] = sorted(set(record.get("validation_errors", []) + ["PdfTool identity fixture digest does not match input SHA-256"])) + record["status"] = "failed" + records.append(record) + + failed = sum(record["status"] == "failed" for record in records) + flagged = sum(record["required"] and record["status"] in {"flagged", "unavailable"} for record in records) + skipped = sum(not record["required"] and record["status"] == "unavailable" for record in records) + return { + "schema_kind": MATRIX_KIND, + "schema_version": 2, + "candidate_sha": identity["candidate_sha"], + "candidate_identity": identity, + "generated_at_utc": datetime.now(timezone.utc).isoformat(), + "fixtures": records, + "summary": { + "total": len(records), + "measured": sum(record["status"] == "measured" for record in records), + "flagged": flagged, + "skipped": skipped, + "failed": failed, + "candidate_sha_verified": identity["verified"], + }, + } + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--pdf-tool", type=Path, required=True) + source = parser.add_mutually_exclusive_group() + source.add_argument("--manifest", type=Path) + source.add_argument("--fixture", action="append", default=[], metavar="NAME=PATH") + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--budgets", type=Path, default=DEFAULT_BUDGETS) + parser.add_argument("--baseline", type=Path) + parser.add_argument("--margin", type=float, default=2.0) + parser.add_argument("--repetitions", type=int, default=1) + parser.add_argument("--rasterizers", type=int, default=DEFAULT_RASTERIZERS) + parser.add_argument("--timeout-seconds", type=float, default=120.0) + parser.add_argument("--cancel-fixture", choices=tuple(FIXTURE_SPECS)) + parser.add_argument("--cancel-after-seconds", type=float) + parser.add_argument("--strict", action="store_true", help="fail when required fixtures, provenance, or measurements are unavailable") + args = parser.parse_args(argv) + try: + if args.margin <= 0 or args.timeout_seconds <= 0 or args.repetitions < 1 or args.rasterizers < 1: + raise ValueError("margin, timeout-seconds, repetitions, and rasterizers must be positive") + if args.strict and args.manifest is None: + raise ValueError("--strict requires a fixture --manifest with exact digests and sizes") + if (args.cancel_fixture is None) != (args.cancel_after_seconds is None): + raise ValueError("--cancel-fixture and --cancel-after-seconds must be supplied together") + if args.cancel_after_seconds is not None and args.cancel_after_seconds <= 0: + raise ValueError("cancel-after-seconds must be positive") + if args.cancel_fixture is not None and args.repetitions != 1: + raise ValueError("cancellation probes require --repetitions 1") + fixtures: Mapping[str, Path | Mapping[str, Any]] = _load_fixture_manifest(args.manifest) if args.manifest else _fixture_args(args.fixture) + budgets = json.loads(args.budgets.read_text(encoding="utf-8")) + matrix = run_matrix(args.pdf_tool, fixtures, budgets, args.timeout_seconds, args.baseline, args.margin, repetitions=args.repetitions, rasterizers=args.rasterizers, cancel_fixture=args.cancel_fixture, cancel_after_seconds=args.cancel_after_seconds) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(matrix, indent=2) + "\n", encoding="utf-8") + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"resource-envelope matrix error: {exc}", file=sys.stderr) + return 2 + + print(json.dumps(matrix["summary"], indent=2)) + return 1 if matrix["summary"]["failed"] or args.strict and (matrix["summary"]["flagged"] or not matrix["summary"]["candidate_sha_verified"]) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/resource_envelope/test_budget_exhaustion_corpus.py b/scripts/resource_envelope/test_budget_exhaustion_corpus.py new file mode 100644 index 000000000..e383e46e6 --- /dev/null +++ b/scripts/resource_envelope/test_budget_exhaustion_corpus.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import hashlib +import re +import tempfile +import unittest +import zlib +from pathlib import Path + +from scripts.resource_envelope.budget_exhaustion_corpus import ( + BUILDERS, + assemble_pdf, + generate_corpus, +) + + +class AssemblePdfTest(unittest.TestCase): + def test_rejects_non_contiguous_object_numbers(self) -> None: + with self.assertRaises(ValueError): + assemble_pdf({1: b"<< >>", 3: b"<< >>"}) + + def test_xref_offsets_point_at_the_right_object(self) -> None: + pdf = assemble_pdf({1: b"<< /Type /Catalog >>", 2: b"42"}) + match = re.search(rb"startxref\r?\n(\d+)\r?\n%%EOF", pdf) + assert match is not None + xref_offset = int(match.group(1)) + self.assertEqual(pdf[xref_offset : xref_offset + 4], b"xref") + + header_match = re.match(rb"xref\r?\n0 (\d+)\r?\n", pdf[xref_offset:]) + assert header_match is not None + count = int(header_match.group(1)) + self.assertEqual(count, 3) + position = xref_offset + header_match.end() + for number in range(count): + entry = pdf[position : position + 20] + position += 20 + if number == 0: + continue + offset = int(entry[:10]) + self.assertEqual(pdf[offset : offset + len(f"{number} 0 obj".encode())], f"{number} 0 obj".encode()) + + +class BudgetExhaustionCorpusTest(unittest.TestCase): + def test_generation_is_deterministic(self) -> None: + with tempfile.TemporaryDirectory() as first_dir, tempfile.TemporaryDirectory() as second_dir: + first_manifest = generate_corpus(Path(first_dir)) + second_manifest = generate_corpus(Path(second_dir)) + self.assertEqual(first_manifest, second_manifest) + for case in first_manifest["cases"]: + first_bytes = (Path(first_dir) / case["pdf"]).read_bytes() + second_bytes = (Path(second_dir) / case["pdf"]).read_bytes() + self.assertEqual(first_bytes, second_bytes) + + def test_manifest_has_one_case_per_builder(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest = generate_corpus(Path(directory)) + self.assertEqual(len(manifest["cases"]), len(BUILDERS)) + + def test_every_case_is_small_and_hash_matches_file(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest = generate_corpus(Path(directory)) + for case in manifest["cases"]: + path = Path(directory) / case["pdf"] + data = path.read_bytes() + self.assertLess(len(data), 64 * 1024, f"{case['id']} fixture is unexpectedly large") + self.assertEqual(hashlib.sha256(data).hexdigest(), case["sha256"]) + + def test_every_case_has_required_manifest_fields(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest = generate_corpus(Path(directory)) + seen_kinds = set() + for case in manifest["cases"]: + for field in ("id", "description", "path", "expected", "limits"): + self.assertIn(field, case) + self.assertIn(case["path"], ("session", "reader")) + self.assertIn("kind", case["expected"]) + self.assertIn("pool", case["expected"]) + if case["path"] == "session": + self.assertIn("profile", case) + self.assertIn("checks", case["profile"]) + seen_kinds.add(case["expected"]["kind"]) + + required_kinds = { + "decompression-ratio", + "cumulative-decoded-bytes", + "recursive-content-depth", + "render-operations", + "render-pixels", + "object-depth", + "objects-visited", + } + self.assertEqual(seen_kinds, required_kinds) + + def test_decompression_bomb_ratio_exceeds_its_own_tightened_limit(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest = generate_corpus(Path(directory)) + case = next(item for item in manifest["cases"] if item["id"] == "decompression-bomb") + data = (Path(directory) / case["pdf"]).read_bytes() + stream_match = re.search(rb"stream\r?\n", data) + assert stream_match is not None + start = stream_match.end() + end = data.index(b"\nendstream", start) + compressed = data[start:end] + decoded = zlib.decompress(compressed) + ratio = len(decoded) / len(compressed) + self.assertGreater(ratio, case["limits"]["maxDecompressionRatio"]) + + def test_cumulative_decoded_bytes_case_exceeds_its_own_tightened_cap(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest = generate_corpus(Path(directory)) + case = next(item for item in manifest["cases"] if item["id"] == "cumulative-decoded-bytes") + data = (Path(directory) / case["pdf"]).read_bytes() + total_stream_bytes = sum( + len(match.group(1)) for match in re.finditer(rb"stream\r?\n(.*?)\r?\nendstream", data, re.DOTALL) + ) + self.assertGreater(total_stream_bytes, case["limits"]["maxCumulativeDecodedBytes"]) + + def test_deep_recursive_object_graph_nests_past_its_own_tightened_depth(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest = generate_corpus(Path(directory)) + case = next(item for item in manifest["cases"] if item["id"] == "deep-recursive-object-graph") + data = (Path(directory) / case["pdf"]).read_bytes() + deepest_run = max(len(run) for run in re.findall(rb"\[+", data)) + self.assertGreater(deepest_run, case["limits"]["maxObjectDepth"]) + + def test_pathological_object_count_exceeds_its_own_tightened_cap(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest = generate_corpus(Path(directory)) + case = next(item for item in manifest["cases"] if item["id"] == "pathological-object-count") + data = (Path(directory) / case["pdf"]).read_bytes() + object_count = len(re.findall(rb"\d+ 0 obj", data)) + self.assertGreater(object_count, case["limits"]["maxObjectsVisited"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/resource_envelope/test_run_matrix.py b/scripts/resource_envelope/test_run_matrix.py new file mode 100644 index 000000000..485ffa373 --- /dev/null +++ b/scripts/resource_envelope/test_run_matrix.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import hashlib +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + +from scripts.resource_envelope.run_matrix import ( + FIXTURE_SPECS, + _fixture_args, + _load_fixture_manifest, + run_fixture, + run_matrix, +) +from scripts.resource_envelope.create_fixture_manifest import create_manifest +from scripts.resource_envelope.validate_envelope import POOL_NAMES + + +def _policy() -> dict: + return { + "resource_budget": { + "resident_limit_bytes": 200, + "pool_limits_bytes": {pool: 100 for pool in POOL_NAMES}, + }, + "workloads": { + "pathological-vector": {"page_count": 256, "wall_time_ms": 100, "rss_high_water_bytes": 200}, + }, + } + + +def _envelope(page_count: int = 256, rss: int = 10, elapsed: int = 10, commit: str | None = None, fixture_digest: str | None = None) -> dict: + identity = {} + if commit is not None or fixture_digest is not None: + identity = {"commit": commit, "fixture_digest": fixture_digest} + return { + "identity": identity, + "family": "test", + "status": "incomplete", + "page_count": page_count, + "rss_high_water_bytes": rss, + "preflight_high_water_bytes": -1, + "pages_materialized": page_count, + "elapsed_ms": elapsed, + "prefetch_shed": False, + "interaction_slot_held": True, + "resources": { + "config": {"resident_limit_bytes": 200, "pool_limits_bytes": {pool: 100 for pool in POOL_NAMES}}, + "resident_bytes": 0, + "resident_high_water_bytes": 0, + "pressure": "normal", + "pools": { + pool: {"limit_bytes": 100, "current_bytes": 0, "high_water_bytes": 0, "evictions": 0, "shed": 0} + for pool in POOL_NAMES + }, + }, + } + + +def _metadata(fixture: Path) -> dict: + import hashlib + + return { + "path": str(fixture), + "sha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), + "size_bytes": fixture.stat().st_size, + "provenance": "unit-test fixture", + "page_count": 256, + } + + +class RunMatrixTest(unittest.TestCase): + def test_fixture_argument_rejects_unknown_name(self) -> None: + with self.assertRaises(ValueError): + _fixture_args(["unknown=file.pdf"]) + + def test_run_fixture_extracts_nested_envelope_and_flags_incomplete(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = Path(directory) / "fixture.pdf" + fixture.write_bytes(b"fixture") + import hashlib + digest = hashlib.sha256(b"fixture").hexdigest() + payload = json.dumps({"data": {"workload_envelope": _envelope(commit="candidate-sha", fixture_digest=digest)}}) + + def runner(*args, **kwargs): + return subprocess.CompletedProcess(args[0], 0, payload, "") + + record = run_fixture(Path("PdfTool.exe"), "pathological-vector", fixture, _policy(), 1, runner=runner, metadata=_metadata(fixture), candidate_sha="candidate-sha") + self.assertEqual(record["status"], "flagged") + self.assertEqual(record["result"]["page_count"], 256) + + def test_baseline_regression_fails_record(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = Path(directory) / "fixture.pdf" + fixture.write_bytes(b"fixture") + import hashlib + digest = hashlib.sha256(b"fixture").hexdigest() + payload = json.dumps({"workload_envelope": _envelope(rss=50, elapsed=50, commit="candidate-sha", fixture_digest=digest)}) + + def runner(*args, **kwargs): + return subprocess.CompletedProcess(args[0], 0, payload, "") + + baseline = {"result": _envelope(rss=10, elapsed=10, commit="candidate-sha", fixture_digest=digest)} + metadata = _metadata(fixture) + current = run_fixture(Path("PdfTool.exe"), "pathological-vector", fixture, _policy(), 1, baseline=None, runner=runner, metadata=metadata, candidate_sha="candidate-sha") + baseline["fixture_sha256"] = current["fixture_sha256"] + baseline["identity"] = current["identity"] + record = run_fixture(Path("PdfTool.exe"), "pathological-vector", fixture, _policy(), 1, baseline=baseline, margin=2, runner=runner, metadata=metadata, candidate_sha="candidate-sha") + self.assertEqual(record["status"], "failed") + self.assertEqual(len(record["regressions"]), 2) + + def test_identity_must_match_candidate_and_fixture(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = Path(directory) / "fixture.pdf" + fixture.write_bytes(b"fixture") + import hashlib + payload = json.dumps( + { + "workload_envelope": _envelope( + commit="stale-candidate", + fixture_digest=hashlib.sha256(b"other fixture").hexdigest(), + ), + } + ) + + def runner(*args, **kwargs): + return __import__("subprocess").CompletedProcess(args[0], 0, payload, "") + + metadata = _metadata(fixture) + record = run_fixture( + Path("PdfTool.exe"), + "pathological-vector", + fixture, + _policy(), + 1, + runner=runner, + metadata=metadata, + candidate_sha="candidate-sha", + ) + + self.assertEqual(record["status"], "failed") + self.assertTrue( + any("identity.commit" in error for error in record["validation_errors"]) + ) + self.assertTrue( + any("identity.fixture_digest" in error for error in record["validation_errors"]) + ) + + def test_repetitions_record_conservative_memory_and_median_time(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = Path(directory) / "fixture.pdf" + fixture.write_bytes(b"fixture") + import hashlib + digest = hashlib.sha256(b"fixture").hexdigest() + envelopes = [_envelope(rss=value, elapsed=value, commit="candidate-sha", fixture_digest=digest) for value in (10, 20, 30)] + + def runner(*args, **kwargs): + return subprocess.CompletedProcess(args[0], 0, json.dumps({"workload_envelope": envelopes.pop(0)}), "") + + record = run_fixture(Path("PdfTool.exe"), "pathological-vector", fixture, _policy(), 1, runner=runner, metadata=_metadata(fixture), repetitions=3, candidate_sha="candidate-sha") + self.assertEqual(record["statistics"]["elapsed_ms"]["median"], 20) + self.assertEqual(record["result"]["rss_high_water_bytes"], 30) + + def test_manifest_resolves_relative_paths(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + fixture = root / "fixture.pdf" + fixture.write_bytes(b"fixture") + manifest = root / "manifest.json" + manifest.write_text(json.dumps({ + "schema_kind": "loop-resource-envelope-fixtures", + "schema_version": 1, + "fixtures": [{ + "fixture_id": "pathological-vector", + "path": "fixture.pdf", + "sha256": "0" * 64, + "size_bytes": 7, + "provenance": "unit-test", + }], + }), encoding="utf-8") + loaded = _load_fixture_manifest(manifest) + self.assertEqual(Path(loaded["pathological-vector"]["path"]), fixture.resolve()) + + def test_create_manifest_records_digest_size_and_expected_pages(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = Path(directory) / "fixture.pdf" + fixture.write_bytes(b"fixture") + manifest = create_manifest({"pathological-vector": fixture}, "unit-test generator") + record = manifest["fixtures"][0] + self.assertEqual(record["size_bytes"], 7) + self.assertEqual(record["page_count"], 256) + self.assertEqual(record["provenance"], "unit-test generator") + + def test_matrix_records_missing_required_and_optional_fixtures(self) -> None: + with tempfile.TemporaryDirectory() as directory: + matrix = run_matrix(Path("PdfTool.exe"), {}, _policy(), 1) + self.assertEqual(matrix["summary"]["total"], len(FIXTURE_SPECS)) + self.assertEqual(matrix["summary"]["flagged"], sum(spec["required"] for spec in FIXTURE_SPECS.values())) + self.assertEqual(matrix["summary"]["failed"], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-command-catalog.py b/scripts/verify-command-catalog.py index c605b17fb..e5e7fc3ff 100644 --- a/scripts/verify-command-catalog.py +++ b/scripts/verify-command-catalog.py @@ -59,6 +59,15 @@ SHELL_IMPLEMENTED_COMMANDS = frozenset( { "actionQuit", + "actionFind", + "actionFindNext", + "actionFindPrevious", + "actionFullscreenMode", + "actionPageLayoutContinuous", + "actionPageLayoutSinglePage", + "actionPageLayoutTwoColumns", + "actionPageLayoutTwoPages", + "actionProperties", } ) @@ -292,24 +301,24 @@ def check_shortcut_parity( for action_id, expected in sorted(expected_by_id.items()): command = commands.get(action_id) if command is None: - errors.append(f"{action_id}: has a Widgets shortcut but no catalog entry") + errors.append(f"{action_id}: has a legacy UI shortcut but no catalog entry") continue actual = command.get("shortcut") if actual is None: errors.append( - f"{action_id}: the Widgets shell binds {expected!r} but the catalog " + f"{action_id}: the legacy UI binds {expected!r} but the catalog " "declares no shortcut" ) elif actual != expected: errors.append( - f"{action_id}: catalog shortcut {actual!r} contradicts the Widgets " + f"{action_id}: catalog shortcut {actual!r} contradicts the legacy " f"shell's {expected!r}" ) for action_id, command in sorted(commands.items()): if "shortcut" in command and action_id not in expected_by_id: errors.append( - f"{action_id}: the catalog invents a shortcut the Widgets shell does " + f"{action_id}: the catalog invents a shortcut the legacy UI does " "not bind; add it to PDFActionManager::initActions first" ) @@ -372,7 +381,7 @@ def validate_catalog( pass else: errors.append( - "Widgets shortcut parity requires both pdfeditormainwindow.cpp and " + "Legacy UI shortcut parity requires both shell source files and " "pdfprogramcontroller.cpp, or neither after Issue 17" ) return errors @@ -384,14 +393,14 @@ def verify() -> str: raise ContractError("\n".join(f" - {error}" for error in errors)) policy = load_policy(POLICY_PATH) - implemented = len(IMPLEMENTED_COMMANDS) + implemented = len(IMPLEMENTED_COMMANDS | SHELL_IMPLEMENTED_COMMANDS) if MAIN_WINDOW_PATH.is_file() and CONTROLLER_PATH.is_file(): shortcuts = widget_shortcuts(read_source(CONTROLLER_PATH), CONTROLLER_PATH) shortcut_note = ( f"{len(shortcuts)} shortcuts in parity with PDFActionManager::initActions." ) else: - shortcut_note = "Widgets shortcut parity skipped (Quick shell owns bindings after Issue 17)." + shortcut_note = "Legacy UI shortcut parity skipped (Quick shell owns bindings after Issue 17)." return ( f"Command catalog verified: {len(policy['actions'])} descriptors " f"({implemented} implemented, {len(policy['actions']) - implemented} declared); "