{"owner":"vnotex","repo":"vnote","hasSkills":true,"totalSkillsCount":4,"totalTokensCount":24153,"categories":["root-instruction"],"hasMcp":false,"mcpConfig":null,"found":["tests/AGENTS.md","src/AGENTS.md","AGENTS.md",".github/AGENTS.md"],"skills":{"tests/AGENTS.md":"# Agent Guidelines for tests\n\nTest infrastructure for VNote using the QtTest framework. Parent VNote Qt tests live under\n`tests/` and are built via CMake's `add_qt_test()` helper; vxcore has a **separate** suite under\n`libs/vxcore/tests/` with its own build dir and `add_vxcore_test()` helper. Tests exercise\nservices, models, controllers, and utilities against the vxcore C backend in isolated test mode.\n\n## Two test locations — pick the right one\n\nVNote has tests in **two separate locations** with different registration helpers and build\nconfigurations. Pick the right one or your test will silently never run.\n\n### Parent VNote tests (`tests/`)\n\nBuilt with the parent project into `build-debug/tests/<category>/` **when `-DVNOTE_BUILD_TESTS=ON`\nis set** (it defaults to OFF; CI turns it ON). Registered via\nthe `add_qt_test()` helper (details in [Adding New Tests](#adding-new-tests) below). Categories:\n`controllers/`, `core/`, `gui/`, `integration/`, `models/`, `utils/`, `widgets/`.\n\n`VNOTE_BUILD_TESTS` also controls the bundled vtextedit test targets. VNote sets\n`VTEXTEDIT_BUILD_TESTS` from that flag; standalone vtextedit keeps its own default ON.\n\nConfigure the test-enabled build dir from repo root (also re-run this if `tests/CMakeLists.txt`\nitself changed): `cmake -B build-debug -DVNOTE_BUILD_TESTS=ON`.\n\n**Build + run:**\n```powershell\ncmake --build build-debug --config Debug --target test_yourthing\nctest --test-dir build-debug -C Debug -R \"^test_yourthing$\" --output-on-failure\n```\n\n### vxcore submodule tests (`libs/vxcore/tests/`)\n\n**Separate build dir required** — the parent `build-debug/` does NOT compile vxcore tests. Use a\ndedicated build dir configured with `-DVXCORE_BUILD_TESTS=ON`. Each test is a standalone\nexecutable (file basename = test target name) registered via `add_vxcore_test()`.\n\n**Add a new test:**\n1. Create `libs/vxcore/tests/test_yourthing.cpp` with its own `main()`. Subtests are functions that return `int` (0 = pass); call them via `RUN_TEST(...)` from `test_utils.h`. Always start `main()` with `vxcore_set_test_mode(1)` so the test uses `%TEMP%\\vxcore_test*` instead of real AppData.\n2. Register in `libs/vxcore/tests/CMakeLists.txt`:\n   ```cmake\n   add_vxcore_test(test_yourthing)\n   ```\n   The helper auto-links `vxcore` and adds `${CMAKE_SOURCE_DIR}/src` + `third_party` include dirs.\n3. Test executables CANNOT call vxcore-internal symbols that lack `VXCORE_API` — they live outside the DLL. If you need an internal helper (e.g., `GetCurrentTimestampMillis()`), re-derive it locally in the test file rather than exporting it.\n\n**Configure (once per machine, or after CMake version upgrade):**\n```powershell\ncmake -S libs/vxcore -B libs/vxcore/build_test -G \"Visual Studio 17 2022\" -A x64 -DVXCORE_BUILD_TESTS=ON\n```\n\n**Build + run:**\n```powershell\ncmake --build libs/vxcore/build_test --config Debug --target test_yourthing\nctest --test-dir libs/vxcore/build_test -C Debug -R \"^test_yourthing$\" --output-on-failure\n```\n\n### Universal rules\n\n- **Anchor the ctest regex** with `^...$` to avoid false matches (e.g., `-R \"^test_sync$\"` won't sweep in `test_session_persistence`).\n- After a CMake version upgrade, stale build dirs fail to reconfigure with errors like `CMakeSystem.cmake.in does not exist`. Delete the offending build dir and re-run the configure command from scratch — do NOT try to repair in place.\n- After modifying a touched module, run the FULL test target for that module (not just your new subtest) to catch regressions; vxcore tests print each subtest name to stdout so you can verify the new one ran.\n\nChild-process readiness is a byte-stream protocol: accumulate stdout until the complete\nmarker arrives under one deadline. IPC slow-start fixtures must use blocking reads rather\nthan pumping the primary event loop while waiting for that marker.\n## Test Structure\n\n```\ntests/\n├── CMakeLists.txt          # Root test config with add_qt_test() helper\n├── helpers/\n│   ├── CMakeLists.txt\n│   ├── test_helper.h       # Common includes\n│   └── temp_dir_fixture.h  # QTemporaryDir wrapper (+ copyFrom() for fixtures)\n├── data/                   # On-disk test fixtures (see \"Test Data Fixtures\" below)\n│   └── vnote3_notebooks/\n│       └── database_notebook/  # Real VNote3 notebook with 17 files + subfolder\n├── core/\n│   ├── CMakeLists.txt\n│   ├── test_error.cpp\n│   ├── test_exception.cpp\n│   ├── test_configservice.cpp\n│   ├── test_notebookservice.cpp\n│   ├── test_bufferservice.cpp   # BufferCoreService tests\n│   ├── test_buffer.cpp          # Buffer2 + BufferService integration tests (29 cases)\n│   └── test_vnote3migrationservice.cpp  # VNote3 migration tests (46 cases)\n└── utils/\n    ├── CMakeLists.txt\n    ├── test_pathutils.cpp\n    └── test_htmlutils.cpp\n```\n\n## CRITICAL: Test Mode for vxcore\n\n**Always enable test mode BEFORE creating vxcore context in tests:**\n\n```cpp\nvoid TestMyService::initTestCase() {\n  // CRITICAL: Must call BEFORE vxcore_context_create()\n  // Prevents tests from corrupting real user data\n  vxcore_set_test_mode(1);\n\n  m_ctx = vxcore_context_create();\n  // ...\n}\n```\n\n**Why this matters:**\n- Without test mode, vxcore uses real `AppData/Local` paths\n- Tests will corrupt actual user configuration\n- Test mode redirects to isolated temp directories\n\nEncryption fixtures created by `prepare_notebook` / `commit_notebook` are self-contained v2\nnotes. Reopen them with `vxcore_encryption_unlock_note`, using the notebook-relative path\ncaptured before closing the buffer; `unlock_notebook` is only for explicit legacy-vault tests.\n\n## Writing Service Tests\n\n```cpp\n// tests/core/test_myservice.cpp\n#include <QtTest>\n\n#include <vxcore/vxcore.h>\n\n#include <core/myservice.h>\n\nnamespace tests {\n\nclass TestMyService : public QObject {\n  Q_OBJECT\n\nprivate slots:\n  void initTestCase();\n  void cleanupTestCase();\n  void testBasicOperation();\n\nprivate:\n  vxcore_context *m_ctx = nullptr;\n};\n\nvoid TestMyService::initTestCase() {\n  vxcore_set_test_mode(1);  // CRITICAL!\n  m_ctx = vxcore_context_create();\n}\n\nvoid TestMyService::cleanupTestCase() {\n  vxcore_context_destroy(m_ctx);\n  m_ctx = nullptr;\n}\n\nvoid TestMyService::testBasicOperation() {\n  vnotex::MyService service(m_ctx);\n  QVERIFY(!service.doSomething().isEmpty());\n}\n\n} // namespace tests\n\nQTEST_GUILESS_MAIN(tests::TestMyService)\n#include \"test_myservice.moc\"\n```\n\n## Enable Tests\nEnable parent and bundled editor tests explicitly at configure time; do not edit CMake files:\n```bash\ncmake -S . -B build -DVNOTE_BUILD_TESTS=ON\n```\n\nCMake retains cached choices. An existing ON build stays ON even though the default is OFF;\nconfigure with `-DVNOTE_BUILD_TESTS=OFF` to remove both sets of test targets from that build.\n\n## Build Tests\n```bash\ncmake --build build --config Release --target test_error test_exception test_pathutils test_htmlutils\n```\n\n### Test Libraries\n\n| Library | Purpose | Links |\n|---------|---------|-------|\n| `core_services` | Service layer (ConfigCoreService, NotebookCoreService, etc.) | Qt6::Core, Qt6::Gui, Qt6::Network, Qt6::Concurrent, vxcore, core_net |\n| `core_net` | Qt-Core/Network-only HTTP helpers (`vnotex::NetworkAccess`) | Qt6::Core, Qt6::Network |\n| `core_configs` | Config classes (ConfigMgr2, MainConfig, SessionConfig, etc.) | core_services, VTextEdit |\n\n`core_services` links **neither `Qt::Widgets` nor `VTextEdit`** — see [`../src/core/services/AGENTS.md`](../src/core/services/AGENTS.md#core_services-is-qt-widgets-free-and-vtextedit-free-contract). Practical consequence for tests: a target that needs QtWidgets or vtextedit headers must say so in its own `LINKS`; nothing arrives transitively through `core_services` any more.\n\n## Run Tests\n\n**Windows (Qt DLLs must be in PATH):**\n```powershell\n$env:PATH = \"C:/Qt/6.9.3/msvc2022_64/bin;\" + $env:PATH\n./build/tests/core/test_error.exe\n./build/tests/core/test_configservice.exe\n./build/tests/core/test_notebookservice.exe\n```\n\n**Using CTest (requires Qt in system PATH):**\n```bash\nctest --test-dir build                    # Run all tests\nctest --test-dir build -R test_error      # Run single test (pattern match)\nctest --test-dir build --output-on-failure  # Show output on failure\n```\n\n**macOS keychain tests:** core tests that exercise real QtKeychain jobs use\n`VNOTE_KEYCHAIN_TEST_MAIN` from `test_helper.h`: a native GUI application on macOS,\n`QCoreApplication` elsewhere. On macOS, run these and keychain-using widget tests with\n`QT_QPA_PLATFORM=cocoa`, not `offscreen`: QtKeychain completes on the native main queue,\nwhich the offscreen UNIX dispatcher does not drain. Do not set\n`QT_EVENT_DISPATCHER_CORE_FOUNDATION`: it also replaces worker-thread dispatchers and\ncan stall queued work. Tests also need an unlocked default keychain; macOS CI creates\na disposable one after compilation, immediately before CTest, so the unlock lease covers\nexecution rather than the cold build. It restores the previous default after testing.\n\nFor asynchronous modal-error tests, wait for controller completion with `QSignalSpy::wait`.\nKeep the dismissal timer active until completion and restrict it to the tested dialog's\nmessage box. Do not stop on the first click or infer dialog readiness from\n`QThread::loopLevel()`.\n\n`ConsentDialogDriver` shares the native resource/preview consent fixture. It owns a\nparent-matched `QPointer<QMessageBox>` and an answer timer through dispatch and destruction\nof the completed message box, not just until the context menu or click returns. A Cocoa native alert's QWidget is a hidden\nmirror (`WA_DontShowOnScreen`), not its interactive surface: click the real NSButton's\nYes/No/YesToAll tag on the active native alert, or the parent's attached sheet for WindowModal.\nNever re-show or click that dummy QWidget during native modal startup. The QWidget fallback\nrequires the matching visible active modal and its visible expected button. Cocoa uses\nan `NSTimer` in common/modal/tracking run-loop modes: a native alert nested inside manual\nQt event dispatch can suppress QTimer activation. Invalidate before answering and on scope\nexit; the same original eight-second deadline covers readiness and completion. Native Escape\nuses the alert's key-equivalent path; Close uses `QMessageBox::close()` to end its native\nmodal helper as well. Neither may be replaced with a No-button click.\n\n**Native WebEngine raster tests:** on macOS, `test_mindmapexport`, `test_htmlpdfexporter`,\n`test_markdownwebjobs` and `test_webviewservice` use `cocoa` and the matching Qt kit's\nplatform plugin, not `offscreen`, so hidden views have native graphics contexts.\nKeep deployed-app WebEngine overrides cleared and non-macOS environments unchanged.\n\nNative Markdown render waits observe the adapter's completed document generation and\nactual DOM, not the independent web-job handshake. JavaScript observations pump events;\nuse one `QTest::qWaitFor` and recheck the generation after observation, rather than a\n`QTRY` predicate that can become false again during its post-success evaluation.\nIf the next assertion requires native currentness, use `waitForCurrentRender`, not the\nDOM-only `waitForRender`; protected/public handoffs retain all token and stale-work checks.\n\nTypst context-menu fixtures select Paste by Qt's `edit-paste` action ID and retire\nresources after action admission, not from a timer that can fire during Cocoa's menu\nflash. Empty clipboard MIME data is nullable. Native drag fixtures must release input\nwithout depending on GUI timers: Windows OLE and Cocoa tracking loops can starve them.\nUse joined native-input workers, restoring the cursor and preventing late input from\nreaching the next case; Cocoa retains app-local NSEvents rather than global HID input.\nWindows drag motion uses non-coalesced `SendInput` records in normalized virtual-desktop\ncoordinates. After selecting text, acquire foreground, repaint, deliver posted `LayoutRequest`\nevents, repaint the resolved gutter layout and complete `DwmFlush` before measuring positions.\nAfter source-pointer receipt, recheck mapped start/end and `widgetAt` before pressing: a\nfirst-paint gutter update must not move the viewport underneath cached coordinates.\nFailure diagnostics record native move/press/activation messages and GUI-thread state.\nQuery `GetGUIThreadInfo(guiThreadId)` from the worker, not its own GetFocus/GetCapture;\nnever consume or reroute an event just to make the fixture pass.\nWindows workers acknowledge the press and threshold, then post a registered native message to\nthe source HWND. The GUI filter acknowledges it only while an owned `QDrag` exists. Send one\ndistinct bootstrap motion, await capture transfer from the fixture to a GUI-thread window,\nthen send the final destination; an already-posted capture never skips the bootstrap motion.\nAll waits share the original deadline. Qt timers or waiting for DragEnter before startup motion\ncan deadlock in Qt's filtered native pump. Destination arrival can be DragEnter or DragMove.\nRelease follows arrival or the bounded deadline; joined workers always attempt release and\nrestore the saved pointer. Cocoa\ncreates timestamped NSEvents at dispatch, without warping ahead of delivered source events.\nCreate native peers before measuring drag coordinates. On Cocoa, find the source viewport's\nnearest already-native QWidget ancestor without creating a peer. Its flipped QNSView coordinates\nmatch Qt's window coordinates: map locally to that ancestor, then use `convertPoint:toView:nil`\nfor the event and `convertRectToScreen:` plus the primary-screen Y flip for the Quartz pointer.\nDo not mix Qt's cached global frame origin into native input or compensate with titlebar offsets.\nTypst shortcut/focus tests use xcb under Xvfb on Linux, not offscreen. Keep the normal\nQtTest crash handler. Build the host Typst module's `test_typstviewwindow_insertion` executable\nonce, then select `^test_typstviewwindow_(insertion|cleanup|resources)$` in CTest. These serial\ngroups share the executable and each retain a 240-second deadline. With `VNOTE_TYPST_TEST_GROUP`\nunset, a direct executable launch runs all default-enabled cases. Each group writes plaintext stdout and\nits own `test_typstviewwindow_<group>.xml` JUnit file in the CTest working directory. CI\npreserves original parent/native CTest logs and these reports before diagnostic reruns,\nsince hosted stdout can omit assertions.\n\nThe controller executable is also partitioned without extending deadlines. Build\n`test_typstviewwindowcontroller` once and run\n`^test_typstviewwindowcontroller(_images|_resources)?$`: the base registration covers\nnavigation, followed by image validation/storage and resource acquisition/viewing.\nAll three are serial, retain 60-second deadlines and `-nocrashhandler`, and use the\nsame native runtime environment. Empty/unset `VNOTE_TYPST_CONTROLLER_TEST_GROUP` runs\nall cases directly; unknown groups fail. Slot boundaries `imageStorage()` and\n`resourceSourceRejection()` keep every test in exactly one group.\n\nThe `resourceSnapshotBytes(retained-gif-mime-not-replaced-file)` row is disabled by default.\nSet `VNOTE_TEST_RETAINED_GIF_SNAPSHOT=1` to register it; only the literal `1` enables it.\nThe PNG snapshot row and every other resource test remain enabled. Windows Clipboard\nUser Service can interfere with the GIF row on a shared desktop; the opt-in does not\nchange its assertions or convert failures into skips.\n\nAll three parent CI workflows set this flag, including for diagnostic reruns. **Pre-push\nverification must also set it when this suite is affected**; a default local pass is not\nproof of the full CI/pre-push coverage. No build reconfiguration is needed:\n\n```powershell\n$env:VNOTE_TEST_RETAINED_GIF_SNAPSHOT = \"1\"\nctest --test-dir build-debug -C Debug -R '^test_typstviewwindow_resources$' --output-on-failure\n```\n\n```bash\nVNOTE_TEST_RETAINED_GIF_SNAPSHOT=1 ctest --test-dir build-debug -R '^test_typstviewwindow_resources$' --output-on-failure\n```\n\nSet Qt's active window explicitly at focus-sensitive fixture operations; `activateWindow()`\nalone depends on a window manager under Xvfb. After dismissing a modal dialog, wait for\nwindow activation and editor focus before sending shortcuts. Settle native exposure and\nactivation before measuring drag coordinates. Before inserting tab-replacement or presentation\ndrafts programmatically, wait for editor focus: that registers the active writer used by Save.\nThe lazy-view `showWindow()` helper establishes those exposure/activation/focus preconditions;\ndo not require editor focus for hidden/disabled editors or encrypted buffers already locking.\nNative popup fixtures must settle the owner's activation before promotion/opening. A\ntransient parent shares `QWindow::isActive()`; when exact ownership matters, wait for both\n`QGuiApplication::focusWindow()` and `QApplication::activeWindow()` to match the intended host.\n`activateNativeHost()` observes the target's closest existing native surface, not only the\nroot window: fullscreen native children can cover the root's entire exposed region. Retain\ncurrent host/surface ancestry and exact native activation checks, without creating peers or\nforcing the child focus that a test is meant to observe. After modal cancellation, restore\nthe fixture's native top-level activation if needed, then observe automatic child-focus\nrestoration; never call `setFocus()` to supply the child focus under test. Native dialog\ninput similarly waits for `QGuiApplication::focusObject()`, not just QWidget `hasFocus()`.\nContext-menu fixtures wait for both selection and deselection to reach WebEngine before clicking.\nUse an explicit blank non-text region for an unselected context: Cocoa/Chromium can select\nthe word under a right-click even after the fixture cleared selection. Keep selected-link,\nimage and empty-context reset assertions distinct.\n\n`test_viewarea_tab_reuse` retains only its unmodified, version-stamped bundled asset directories\nbetween QtTest rows. It removes all local state and all non-bundle app entries before creating\na fresh context, config, notebook, services and WebEngine profile. Obtain cleanup paths from\nConfigMgr2, and prune only in the test class `init()`, never inside `Fixture::initialize()`:\nintra-row fixture recreation intentionally tests persisted sessions. This suite must not edit\ninstalled assets, including `web/css/user.css` or user-created templates; asset-customization\nfixtures must retain the full test-directory reset. Keep the existing suite deadlines.\n\nPDF.js text-item boundaries are not word boundaries: ligatures can split a word across\nitems. Reconstruct text by concatenating `item.str` and adding a newline only for\n`item.hasEOL`, as the bundled viewer does; do not insert spaces between every item.\n\n### Local pre-push flaky-test whitelist\n\nOn a **shared interactive Windows desktop only**, the following native-input failures\nmay be treated as non-gating for a local push. This is a closed list of failure sites,\nnot permission to skip an executable or any product-behavior assertion.\n\n| Executable | Exact QtTest case / row | Eligible failure site |\n|---|---|---|\n| `test_viewarea_tab_reuse` | `previewReaderNavigationAndNativeHost` | First source-reader `B` link's `clickNativeSurface` hit-test precondition |\n| `test_viewarea_tab_reuse` | `previewOpenInNewTabs(main-source)` | Open Link button's `clickNativeSurface` hit-test precondition |\n| `test_viewarea_tab_reuse` | `previewOpenInNewTabsDuplicateConsent(other-tab-save)` | Incoming editor's `hasFocus()` precondition before consent/promotion |\n\n**All** of these conditions are required for each use:\n\n1. Build the affected targets and attempt the **full executable** at the revision being\n   pushed. Preserve the first failure report. Windows GUI executables may omit QtTest\n   assertions from CTest's captured stdout; use explicit QtTest `-o <path>,txt` and\n   `-o <path>,junitxml` outputs for diagnosis with the registered runtime environment.\n2. Match both the exact row and failure site above. For a click failure, diagnostics must\n   show a visible, in-bounds target, an inactive native window, and null/foreign\n   `QApplication::widgetAt()` at the click position. For the editor-focus failure, also\n   establish that the native window was inactive. A bare `clickNativeSurface`/`hasFocus`\n   failure or a passing retry alone is **not** enough to classify the failure.\n3. The affected case must pass in isolation at the same revision, and all remaining rows\n   and other affected suites must run and pass. Prefer an exclusive desktop run when\n   available. Do not retry unchanged runs merely until one passes; reruns must investigate\n   a cause or follow an environment correction.\n4. The unpushed changes must not modify the affected native preview input, focus, geometry,\n   promotion, hit-test helpers, fixture activation or relevant platform configuration.\n   Those changes require a clean native verification run or a separate explicit user exception.\n5. Report the exact case, native-state evidence and local waived gate as a **skip**, not\n   a pass. Any other failure, including later content, routing, save/discard/cancel,\n   persistence assertions, crashes or timeouts, remains blocking even in a listed case.\n\nCI and packaging gates are never waived. Do not add `QSKIP`, exclude the executable or\nrows from CTest, weaken assertions, or change deadlines to implement this policy.\nReassess/remove an entry when its native activation cause is fixed.\n\nThe 2026-10-29 investigation observed a visible/in-bounds preview button with an inactive\nnative window and null `widgetAt`, while failing rows varied across runs; the unmodified\nfull suite subsequently passed all 73 cases. That earlier fixture requested activation\nwithout waiting for native activation; the current fixture observes its owned native surface.\nThe original reader-click rejection and editor-focus\nfailure did not identify a covering process or independently prove their native-window\nstate, so those assertion messages alone do not qualify for the exception.\n\n### VTextEdit.dll runtime copy\n\nTests that link `VTextEdit` (directly, or transitively via `core_configs`) load `VTextEdit.dll` at runtime. **`core_services` alone no longer pulls it in**, so a pure-core test that links only `core_services` + `vxcore` runs with no `VTextEdit.dll` anywhere near it. The build copies the DLL next to each subdirectory's test exes via a `POST_BUILD` step anchored on one test target per subdir (the canonical reference is `tests/utils/CMakeLists.txt:78-83`):\n\n```cmake\n# Copy VTextEdit DLL next to test executable so CTest can find it at runtime.\nadd_custom_command(TARGET test_clipboard_image POST_BUILD\n  COMMAND ${CMAKE_COMMAND} -E copy_if_different\n    $<TARGET_FILE:VTextEdit>\n    $<TARGET_FILE_DIR:test_clipboard_image>\n)\n```\n\nWith Typst enabled, `VTextEdit.dll` directly depends on `vtextedit_typst.dll`. The common\n`add_qt_test()` supplies Windows CTest lookup paths for Qt, VTextEdit and the native backend\nwithout linking pure-core tests to them. Tests replacing ENVIRONMENT must restore PATH\nafterward; append it separately to each test instead of copying an escaped PATH list.\n\n**When adding tests to a NEW subdirectory under `tests/` whose targets link `VTextEdit`**, that subdir's `CMakeLists.txt` MUST include this `POST_BUILD` block anchored on any one such test target. Without it, `ctest` reports the test as failed with exit code `0xc0000135` (Windows \"DLL not found\") and the test binary never reaches its `main()`. One copy per subdir is enough; `copy_if_different` makes incremental rebuilds free.\n\n### Running execs that depend on VTextEdit.dll (CRITICAL — avoid false-positive smoke tests)\n\n`build-debug/src/vnote.exe` and widget/editor tests load `VTextEdit.dll` and its native\n`vtextedit_typst.dll` dependency, plus the matching Qt 6 runtime. The editor/runtime DLLs\nare staged in `build-debug/bin` and beside VNote; Typst test targets also refresh their\nlocal copies. Pure-core tests linked only to `core_services` + `vxcore` remain independent\nof VTextEdit. Direct test launches and disposable app copies still need the configured\nQt/runtime lookup paths. A missing DLL can show a loader modal before `WinMain`.\n\n**This is a verification trap**: when the loader dialog blocks the process, the OS reports the process as alive (it has a PID, is technically running), so naive checks like `Start-Process … -PassThru` + `Sleep` + `HasExited` return `$false` → you falsely conclude the binary started. The process is actually frozen in pre-WinMain limbo waiting for someone to click the dialog. `Stop-Process -Force` afterwards silently dismisses the dialog and the lie is preserved.\n\n### Agent GUI smoke testing (Windows)\n\nUse `--no-single-instance` to test a separate VNote process without closing the user's running\ninstance. **It skips locking and IPC, not data isolation.** Never launch the agent's GUI smoke test\nagainst the normal profile or real notebooks. Create a fresh portable copy for each run; its\nexisting `config/` folder redirects config, session and WebEngine data to that copy.\n\nBefore using this recipe, check the OS Documents folder's `my_notebook` path. If it is missing or\nan empty directory, first-run setup can create/open a bundled notebook there, **outside the portable\ncopy**; use a disposable OS account instead. Do not edit, empty, or delete the user's directory to\nsatisfy the collision guard. Continue testing only the scratch file in the disposable copy.\n\nRun from the repository root after building `vnote`. Set `$build` to the build you just verified;\nthe example derives the matching Qt runtime from its CMake cache, rather than mixing Qt versions.\nRun both blocks in the same persistent PowerShell terminal, keeping it open during GUI inspection\nand cleanup; avoid a one-shot wrapper that captures inherited process pipes.\n\n```powershell\n$ErrorActionPreference = \"Stop\"\n$build = \"build-debug\" # Replace with the existing build you just compiled\n$qtDir = (Select-String -Path \"$build/CMakeCache.txt\" -Pattern '^Qt6_DIR:PATH=' |\n          Where-Object { $_.Line -notmatch 'NOTFOUND$' } | Select-Object -First 1).Line\nif (-not $qtDir) { throw \"No configured Qt directory in $build/CMakeCache.txt\" }\n$qtBin = [IO.Path]::GetFullPath((Join-Path ($qtDir -split '=', 2)[1] \"../../../bin\"))\n$env:PATH = \"$qtBin;\" + (Resolve-Path \"$build/bin\").Path + \";\" + $env:PATH\n$env:QT_QPA_PLATFORM_PLUGIN_PATH = Join-Path $qtBin \"../plugins/platforms\"\n$env:QTWEBENGINEPROCESS_PATH = Join-Path $qtBin \"QtWebEngineProcess.exe\"\n\n$smoke = Join-Path $build (\"agent-smoke-\" + [guid]::NewGuid().ToString(\"N\"))\nNew-Item -ItemType Directory -Path \"$smoke/config\" | Out-Null\nCopy-Item \"$build/src/vnote.exe\", \"$build/src/vnote_extra.rcc\", \"$build/src/qt.conf\" $smoke\nif (Test-Path \"$build/src/translations\") {\n  Copy-Item \"$build/src/translations\" \"$smoke/translations\" -Recurse\n}\nSet-Content -Path \"$smoke/smoke.txt\" -Value \"Disposable agent smoke-test note\" -Encoding UTF8\n$proc = Start-Process -FilePath \"$smoke/vnote.exe\" -PassThru `\n  -ArgumentList @(\"--no-single-instance\", \"--log-stderr\", \"`\"$smoke/smoke.txt`\"\") `\n  -RedirectStandardError \"$smoke/stderr.log\" `\n  -RedirectStandardOutput \"$smoke/stdout.log\"\nStart-Sleep -Seconds 5\n$proc.Refresh()\nif ($proc.HasExited) { throw \"VNote exited with $($proc.ExitCode); inspect $smoke/stderr.log\" }\nforeach ($module in @(\"VTextEdit.dll\", \"vtextedit_typst.dll\")) {\n  if (-not ($proc.Modules | Where-Object { $_.ModuleName -ieq $module })) {\n    throw \"$module not loaded: check runtime DLLs, not just process liveness\"\n  }\n}\nWrite-Output \"Inspect PID $($proc.Id): its own window must display smoke.txt and the note text\"\n```\n\nThe runtime check is only a prerequisite: inspect the **actual window and requested file**, and\nexercise the feature under test. The main window title can remain `VNote`; do not require the file\nname in that title. Startup/file-open diagnostics are in `$smoke/stderr.log`. Add `--detached-view`\nwhen testing detached windows. Use only scratch notes/notebooks and no production sync credentials.\n\nQuit through VNote's UI (answer **No** if asked to minimize to the system tray), allow save/sync\nworkers to drain, then clean up only this run's copy:\n\n```powershell\n$proc.WaitForExit()\nRemove-Item -LiteralPath $smoke -Recurse -Force\n```\n\nDo not reuse the generated profile for the next run: loading its saved session also performs\nnormal Windows launch-on-login reconciliation. Do not enable launch-on-login in a disposable copy.\nIn-app restart drops the flag and uses the normal guard; start another fresh copy explicitly instead.\nChanging `TEMP`/`TMP` does not isolate the fixed IPC name. For guard/forwarding tests use the unique\nserver/lock overrides in `test_singleinstanceguard`, never the user's production pipe or lock.\nBehavior contract: [Agent / manual testing bypass](../src/core/AGENTS.md#agent--manual-testing-bypass).\n\nThe same PATH prepend is required before running parent `test_*.exe` directly. `ctest --test-dir build-debug` inherits the caller's PATH, so set it once at the start of the session.\n\nvxcore submodule tests (`libs/vxcore/build_test/bin/Debug/test_*.exe`) do NOT depend on Qt or VTextEdit and need no PATH setup.\n\n## Adding New Tests\nUse the `add_qt_test()` helper function in CMakeLists.txt:\n\n```cmake\n# tests/module/CMakeLists.txt\nadd_qt_test(test_myclass\n  SOURCES\n    test_myclass.cpp\n    ${CMAKE_SOURCE_DIR}/src/module/myclass.cpp\n  LINKS\n    Qt6::Gui  # Optional: extra Qt modules\n  GUILESS     # Optional: do NOT link Qt6::Widgets\n)\n```\n\n### The `GUILESS` flag is load-bearing (linking only)\n\n`GUILESS` controls **one thing**: whether `add_qt_test` adds `Qt6::Gui` + `Qt6::Widgets` to the target. Non-GUILESS targets get them; GUILESS targets do not. This became load-bearing when `core_services` stopped linking `VTextEdit` (which used to leak `Qt::Widgets` in transitively) — see [`../src/core/services/AGENTS.md`](../src/core/services/AGENTS.md#core_services-is-qt-widgets-free-and-vtextedit-free-contract).\n\nIt does **not** — and cannot — choose between `QTEST_MAIN` and `QTEST_GUILESS_MAIN`. That is a source-level macro decision made at the bottom of the test `.cpp`. The two are related but independent:\n\n- A test using `QTEST_GUILESS_MAIN` that merely **includes** a QtWidgets header (without ever constructing a `QApplication`) is correctly labelled `GUILESS` and should list `Qt6::Widgets` explicitly in `LINKS`. `test_clipboard_image`, `test_exportcontroller`, `test_findunitedentry`, `test_missing_nodes_qt`, `test_duplicate_open_guard`, `test_newnotebookcontroller`, and `test_openvnote3notebookcontroller` all do exactly this.\n- Never \"fix\" a missing-QtWidgets compile error by relaxing `core_services`. Add the module the failing target actually needs to that target's `LINKS`.\n\n## Qt Test Macros\n\n| Macro | Purpose |\n|-------|---------|\n| `QTEST_MAIN(Class)` | Creates main(), runs tests with QApplication |\n| `QTEST_GUILESS_MAIN(Class)` | Headless test runner (preferred) |\n| `QVERIFY(condition)` | Assert condition is true |\n| `QCOMPARE(actual, expected)` | Assert equality |\n| `QFETCH(type, name)` | Fetch data-driven test value |\n| `QTest::addColumn<T>(\"name\")` | Declare data column |\n| `QTest::newRow(\"name\")` | Add data row |\n| `QSKIP(\"reason\")` | Skip test |\n| `QTest::ignoreMessage(type, msg)` | Suppress expected qDebug/qWarning/qCritical |\n\n## Test Data Fixtures\n\nTests that need realistic directory structures (e.g., legacy notebook migration) use on-disk fixtures under `tests/data/` instead of hardcoding JSON and file creation in C++.\n\n### Pattern\n\n1. **Create fixture directory** under `tests/data/` with real files on disk\n2. **Locate at runtime** using `QFINDTESTDATA` (works out of the box with Qt6+CMake)\n3. **Copy to temp dir** using `TempDirFixture::copyFrom()` for test isolation\n\n### Example\n\n```cpp\n// In your test class\nQString findFixture(const QString &p_relPath) {\n  // Path is relative to the test source file\n  QString path = QFINDTESTDATA(p_relPath);\n  QVERIFY2_RETURN(!path.isEmpty(),\n    qPrintable(QStringLiteral(\"Fixture not found: %1\").arg(p_relPath)),\n    QString());\n  return path;\n}\n\nvoid TestMyService::testWithFixture() {\n  // Locate fixture (relative to this .cpp file's directory)\n  QString fixturePath = findFixture(\n    QStringLiteral(\"../data/vnote3_notebooks/database_notebook\"));\n  QVERIFY2(!fixturePath.isEmpty(), \"Fixture not found\");\n\n  // Copy to isolated temp dir\n  TempDirFixture workDir;\n  QString sourceDir = workDir.copyFrom(fixturePath, QStringLiteral(\"source\"));\n  QVERIFY2(!sourceDir.isEmpty(), \"Failed to copy fixture\");\n\n  // Test against the copy\n  // ...\n}\n```\n\n### Adding New Fixtures\n\n1. Create directory under `tests/data/` with descriptive name\n2. Add all files the test needs (stubs are fine — content doesn't have to be real)\n3. For Chinese/Unicode filenames, the files must exist on disk with those names\n4. No CMake changes needed — `QFINDTESTDATA` resolves paths automatically via `QT_TESTCASE_SOURCEDIR`\n\n### Existing Fixtures\n\n| Fixture | Path | Description |\n|---------|------|-------------|\n| database_notebook | `tests/data/vnote3_notebooks/database_notebook/` | VNote3 notebook with 17 files (Chinese + ASCII), 1 subfolder, 3 attachment dirs |\n\n## Real Keychain Usage\n\nAll **new or modified tests** that instantiate the real `SyncCredentialsStore` (not a `MockCredentialsStore` / in-memory fake) MUST register a `tests::KeychainGuard` to track and clean up written PAT entries.\n\n### Why\n\nWithout deterministic cleanup, tests leak PAT entries into the host OS keychain. Fixed-ID tests overwrite themselves harmlessly, but UUID-based writes accumulate forever. QtKeychain has no enumerate API (see `src/core/services/synccredentialsstore.cpp:71`), so cleanup cannot be done by sweeping the keychain at startup; it must be driven by tracking each write.\n\n### How\n\n```cpp\n#include \"../helpers/keychain_guard.h\"\n\n// Class-level member:\ntests::KeychainGuard *m_keychainGuard = nullptr;\n\n// In initTestCase(), after registering SyncCredentialsStore:\nauto *credStore = m_services.get<vnotex::SyncCredentialsStore>();\nm_keychainGuard = new tests::KeychainGuard(credStore, this);\n\n// In cleanupTestCase(), BEFORE destroying the vxcore context:\nif (m_keychainGuard) {\n  m_keychainGuard->cleanup();\n  delete m_keychainGuard;\n  m_keychainGuard = nullptr;\n}\n\n// For indirect writes (e.g. via SyncService::enableSyncForNotebook),\n// also call track() defensively in case the credentialsStored signal races:\nm_keychainGuard->track(notebookId);\n```\n\n### Ordering\n\n`cleanup()` MUST run before the vxcore context is destroyed. The guard talks to `SyncCredentialsStore`, which depends on `ServiceLocator` and the live vxcore context; tearing the context down first leaves the guard with dangling references.\n\n### Cross-reference\n\nFor the prod-side cleanup contract (5 sites in `SyncService`), see [`../src/core/services/AGENTS.md` § Credential Cleanup Invariants](../src/core/services/AGENTS.md#credential-cleanup-invariants).\n\n### Mandatory `KeychainGuard` rollout (post-fix-failing-tests plan)\n\nAll sync tests that instantiate `SyncCredentialsStore` (whether through `ServiceLocator` or by local construction) MUST register a `tests::KeychainGuard`. This is non-negotiable: missing guards cause cumulative `notebook_sync_pat_<uuid>` accumulation in the Windows Credential Manager, eventually tripping Win32 error 8 (\"Not enough storage\") which cascades every sync test into failure on subsequent runs.\n\nAs of 2026-06-05, the following tests are compliant:\n\n- `test_synccredentialsstore` (canonical reference)\n- `test_sync_signal_auto_baseline`, `test_sync_signal_baseline`\n- `test_sync_hooks`, `test_sync_ops`, `test_sync_auto_route`, `test_sync_close_block`\n- `test_bootstrap_and_persist`\n- `test_syncservice`, `test_syncservice_lifecycle`\n\n(10 tests total.) New sync tests MUST follow the template in the \"Real Keychain Usage → How\" section above. Tests that do NOT touch the keychain (`test_eventbridge_sync`, `test_notebookcoreservice_sync`) are exempt.\n\n**Operator note (Windows):** the keychain entry surfaces in `cmdkey /list` as `LegacyGeneric:target=notebook_sync_pat_<uuid>` (CRED_TYPE_GENERIC). `KeychainGuard::cleanup()` delegates to `SyncCredentialsStore::deleteCredentials()`, which handles the full target name internally; operators do NOT need to manage the `LegacyGeneric:target=` prefix manually when invoking the guard.\n\n## Current Test Coverage\n\n| Test | Class | Test Cases |\n|------|-------|------------|\n| test_error | `Error`, `ErrorCode` | 23 |\n| test_exception | `Exception` | 20 |\n| test_pathutils | `PathUtils` | 68 |\n| test_htmlutils | `HtmlUtils` | 31 |\n| test_fileutils2 | `FileUtils2` | 15 |\n| test_configservice | `ConfigCoreService` | 10 |\n| test_notebookservice | `NotebookCoreService` | 33 |\n| test_bufferservice | `BufferCoreService` | - |\n| test_buffer | `Buffer2` + `BufferService` | 29 |\n| test_vnote3migrationservice | `VNote3MigrationService` | 46 |\n| test_searchservice | `SearchCoreService` | - |\n| test_servicelocator | `ServiceLocator` | - |\n| test_configmgr2 | `ConfigMgr2` | 7 |\n| test_hookmanager | `HookManager` | 24 |\n| test_hookintegration | Hook integration | 10 |\n| **Total** | | **267+** |\n\n## Debugging Tips\n\n### First-run Windows theme reports\n\nThe Windows CTest registration for `test_themeservice` writes\n`tests/gui/test_themeservice.txt` and `tests/gui/test_themeservice.xml` in the build tree,\nwhile retaining console output. CI includes both in `native-typst-test-report` before\nany diagnostic rerun can overwrite the local files. Inspect these original reports first;\na passing rerun does not establish that the first-run failure was repaired. File reports\ncapture QtTest diagnostics, not failures occurring before QtTest initializes.\n\n### Windows exit code `0xC0000409` is NOT a stack overrun\n\n`0xC0000409` (`STATUS_STACK_BUFFER_OVERRUN`) is misleading. On Windows, Qt's `qFatal()` calls `__fastfail(FAST_FAIL_FATAL_APP_EXIT)`, which the OS reports as `0xC0000409`. The actual cause is almost always a `qFatal()` deep inside Qt, e.g., `QFontDatabase: Must construct a QGuiApplication before accessing QFontDatabase` raised when a `QTEST_GUILESS_MAIN` test transitively touches font-database code.\n\nWhen a test exits with this code, **re-run it with file-based output capture** to surface the real Qt diagnostic:\n\n```powershell\n$env:PATH = \"C:/Qt/6.9.3/msvc2022_64/bin;\" + $env:PATH\n& \"build/.../tests/<category>/test_name.exe\" -o \"output.txt,txt\"\nGet-Content output.txt | Select-String \"QFATAL|FAIL\"\n```\n\nThe `QFATAL : ...` message line names the real cause. Do NOT chase the test for recursion, oversized stack allocations, or fixture loops; that is a false trail. Fix the underlying Qt precondition (switch to `QTEST_MAIN`, guard the GUI-dependent code path, or short-circuit before the offending Qt subsystem is touched).\n\n## Related Modules\n\n- [`../src/core/AGENTS.md`](../src/core/AGENTS.md) — Services and hooks being tested\n- [`../AGENTS.md`](../AGENTS.md) — Code style, architecture overview\n","src/AGENTS.md":"# src/ — Architecture & Source-Wide Patterns\n\nThis file is inherited by every module under `src/`. It holds the full VNote architecture\n(MVC layering, design-decision rationale, directory tree) and the Qt/C++ patterns that apply\nto all source code regardless of module.\n\nThe normative MVC rules table and the repo-wide build/style rules live in the root\n[AGENTS.md](../AGENTS.md).\n\nPackaging and CI rules — including `src/Packaging.cmake` and Qt 6 deployment — live in\n[.github/AGENTS.md](../.github/AGENTS.md). **An edit to `src/Packaging.cmake` does not\nauto-load that file; open it explicitly.**\n\n---\n\n## Architecture\n\nVNote uses a **clean architecture** with **Model-View-Controller (MVC)** pattern and dependency injection for testability and future plugin support.\n\n### Core Principles\n\n1. **MVC Separation** — Models hold data, Views display it, Controllers handle logic\n2. **Dependency Injection** — No singletons; dependencies passed via ServiceLocator\n3. **Service Layer** — Business logic encapsulated in services, accessed via ServiceLocator\n4. **Hook System** — WordPress-style extensibility for plugins\n\n### MVC Architecture (CRITICAL)\n\nVNote strictly follows the MVC pattern. **All new code MUST adhere to this structure.**\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│                     Controllers                             │\n│  (src/controllers/ - Handle user actions, business logic)   │\n│                                                             │\n│  NotebookNodeController, NewNoteController, etc.            │\n└─────────────────────────────────────────────────────────────┘\n        │                                       │\n        │ Manipulates                          │ Emits signals to\n        ▼                                       ▼\n┌───────────────────────┐       ┌──────────────────────────────┐\n│        Models         │       │           Views              │\n│  (src/models/)        │◄──────│  (src/views/)                │\n│                       │       │                              │\n│  NotebookNodeModel    │ Data  │  NotebookNodeView            │\n│  (QAbstractItemModel) │ flows │  (QTreeView subclass)        │\n└───────────────────────┘       └──────────────────────────────┘\n        │                                       │\n        │ Fetches data from                    │ Receives from\n        ▼                                       ▼\n┌─────────────────────────────────────────────────────────────┐\n│                     ServiceLocator                          │\n│  (DI container - NOT a singleton, passed by reference)      │\n│                                                             │\n│  ┌─────────────────┐  ┌─────────────────────┐  ┌───────────────────┐  │\n│  │ConfigCoreService│  │ NotebookCoreService │  │SearchCoreService  │  │\n│  └─────────────────┘  └─────────────────────┘  └───────────────────┘  │\n└─────────────────────────────────────────────────────────────┘\n                              │\n                              ▼\n┌─────────────────────────────────────────────────────────────┐\n│                         vxcore                              │\n│  (C library: notebook/config/search backend in libs/vxcore) │\n└─────────────────────────────────────────────────────────────┘\n```\n\n### MVC Responsibilities\n\n| Layer | Location | Responsibility | Example |\n|-------|----------|----------------|---------|\n| **Model** | `src/models/` | Data representation, Qt Model/View integration | `NotebookNodeModel` exposes node hierarchy via `QAbstractItemModel` |\n| **View** | `src/views/` | Display data, capture user input, emit signals | `NotebookNodeView` renders tree, emits `nodeActivated` signal |\n| **Controller** | `src/controllers/` | Handle actions, orchestrate Model/View, business logic | `NotebookNodeController` handles new/delete/rename operations |\n| **Service** | `src/core/services/` | Domain operations, data access via vxcore | `NotebookCoreService` wraps vxcore C API for notebook CRUD |\n\n### MVC Example: Notebook Node Operations\n\n```cpp\n// Controller handles user action\nvoid NotebookNodeController::newNote(const NodeIdentifier &p_parentId) {\n  // 1. Emit signal to View to show dialog\n  emit newNoteRequested(p_parentId);\n}\n\n// View shows dialog, then calls back to Controller\nvoid NotebookExplorer2::onNewNoteResult(const NodeIdentifier &p_parentId,\n                                        const NodeIdentifier &p_newNodeId) {\n  m_controller->handleNewNoteResult(p_parentId, p_newNodeId);\n}\n\n// Controller updates Model\nvoid NotebookNodeController::handleNewNoteResult(const NodeIdentifier &p_parentId,\n                                                  const NodeIdentifier &p_newNodeId) {\n  // Model reloads from NotebookCoreService\n  m_model->reloadNode(p_parentId);\n}\n```\n\n### MVC Rules (MUST FOLLOW)\n\nThe normative rules table lives at\n[root AGENTS.md § MVC Rules (MUST FOLLOW)](../AGENTS.md#mvc-rules-must-follow) — that is the\nsingle source of truth, and the anchor `controllers/`, `models/`, `views/` and `widgets/` all\nlink to. Do not copy the table here.\n\n### Key Design Decisions\n\n| Decision | Rationale |\n|----------|-----------|\n| ServiceLocator is NOT a singleton | Enables testing with mock services; explicit dependencies |\n| Services wrap vxcore C API | Qt-friendly interface; encapsulates C interop |\n| Controllers are QObject, not QWidget | Testable business logic without GUI dependencies |\n| Widgets receive `ServiceLocator&` | Constructor injection; no global state |\n| Some files carry a `2` suffix (`MainWindow2`, `Buffer2`, …) | Historical artifact of the now-complete migration off the legacy singleton architecture. The pre-migration counterparts have been removed; the suffix is retained on those existing classes only to avoid a churny rename. The migration is finished, so **new code uses the plain, unsuffixed name unless it genuinely conflicts with an existing type** (e.g. a brand-new `EncodingButton` gets no suffix). Never introduce a `3` suffix. |\n| `Buffer2` is a lightweight copyable handle (like `QModelIndex`) | Returned by `BufferService::openBuffer()`, delegates to `BufferCoreService`; NOT a `QObject`, not heap-allocated |\n| `BufferService` privately inherits `BufferCoreService` | Hook-aware wrapper that fires `vnote.file.*` hooks around core operations |\n| `NodeIdentifier` is a standalone value type | Identifies a node by `notebookId` + `relativePath`; used by `Buffer2`, controllers, and views |\n| `ConfigMgr2` is the ONLY way to access typed config | Owns `MainConfig`/`SessionConfig` with properly merged defaults. NEVER construct a throwaway `MainConfig` from raw JSON — use `m_services.get<ConfigMgr2>()` instead. See `src/core/AGENTS.md` for details. |\n| `ConfigMgr2::getFileFromConfigFolder()` for path resolution | Resolves relative config paths (e.g., `\"web/markdown-viewer-template.html\"`) against the app data directory. Do NOT use `ConfigCoreService::getDataPath()` + manual `QDir::filePath()`. |\n| `PathExists()`/`IsDirectory()`/`IsRegularFile()` wrappers | NEVER pass raw `std::string` to `std::filesystem` — use these wrappers or `PathFromUtf8()` for non-ASCII path safety on Windows |\n\n### Key Design Decisions (ViewArea2 Framework)\n\n| Decision | Rationale |\n|----------|-----------|\n| `ViewAreaController` is the orchestrator | Handles open/close/split/move logic, fires hooks, uses WorkspaceCoreService |\n| `ViewArea2` is a pure view | Owns QSplitter tree + ViewSplit2 instances, no business logic |\n| `ViewSplit2` maps 1:1 to vxcore workspace | Each tab widget pane corresponds to one `WorkspaceCoreService` workspace |\n| `ViewWindow2` receives an initial `Buffer2` in its constructor | Opt-in same-type windows can rebind through the guarded replacement lifecycle while retaining their ID/resources; queued work must check buffer generation. See [tab reuse](widgets/AGENTS.md#note-tab-reuse). |\n| `ViewWindowFactory` maps file types to creators | Registry pattern; plugins register creators for new file types |\n| Splitter orientation follows Vim convention | Left/Right split → `Qt::Horizontal`, Up/Down split → `Qt::Vertical` |\n| Session layout stored as JSON in `SessionConfig` | Recursive splitter tree + workspace IDs for full layout persistence |\n| Concrete ViewWindows live alongside the framework | `MarkdownViewWindow2`, `TextViewWindow2`, `PdfViewWindow2`, `MindMapViewWindow2`, and `WidgetViewWindow2` are registered with `ViewWindowFactory` per file type |\n\n### Directory Structure\n\n```\nsrc/\n├── main.cpp                # Entry point with DI wiring\n├── core/\n│   ├── servicelocator.h    # DI container\n│   ├── nodeidentifier.h    # Lightweight node ID (notebookId + relativePath)\n│   ├── services/           # Service layer (wraps vxcore)\n│   │   ├── configcoreservice.h/.cpp\n│   │   ├── notebookcoreservice.h/.cpp\n│   │   ├── searchcoreservice.h/.cpp\n│   │   ├── filetypecoreservice.h/.cpp\n│   │   ├── buffercoreservice.h/.cpp\n│   │   ├── bufferservice.h/.cpp    # Hook-aware wrapper, returns Buffer2\n│   │   ├── buffer2.h/.cpp          # Lightweight buffer handle (like QModelIndex)\n│   │   ├── templateservice.h/.cpp\n│   │   ├── historyservice.h/.cpp   # Aggregate per-notebook history across notebooks\n│   │   ├── workspacecoreservice.h/.cpp  # Workspace operations (split pane ↔ vxcore workspace)\n│   │   └── hookmanager.h/.cpp\n│   ├── hookcontext.h       # Hook callback context\n│   ├── hooknames.h         # Hook name constants\n│   ├── configmgr2.h/.cpp   # High-level config manager using DI\n│   └── iconfigmgr.h        # Interface for config managers\n├── gui/                    # GUI-aware services and utilities\n│   ├── services/\n│   │   ├── themeservice.h/.cpp         # GUI-aware theme management service\n│   │   └── viewwindowfactory.h/.cpp    # Registry mapping file types to ViewWindow2 creators\n│   └── utils/\n│       ├── widgetutils.h/.cpp          # Widget utility helpers\n│       ├── themeutils.h/.cpp           # Theme utility helpers\n│       ├── imageutils.h/.cpp           # Image utility helpers\n│       └── guiutils.h/.cpp             # General GUI utilities\n├── models/                 # Qt Model/View models\n│   ├── notebooknodemodel.h/.cpp       # QAbstractItemModel for node hierarchy\n│   └── notebooknodeproxymodel.h/.cpp  # Proxy model for sorting/filtering\n├── views/                  # Qt views and delegates\n│   ├── notebooknodeview.h/.cpp        # QTreeView for nodes\n│   ├── notebooknodedelegate.h/.cpp    # Item delegate for node rendering\n│   ├── combinednodeexplorer.h/.cpp    # Composite MVC wiring widget\n│   └── filenodedelegate.h/.cpp        # Item delegate for file list\n├── controllers/            # Controllers (business logic mediators)\n│   ├── notebooknodecontroller.h/.cpp  # Node operations controller\n│   ├── newnotecontroller.h/.cpp       # New note dialog controller\n│   ├── newfoldercontroller.h/.cpp     # New folder dialog controller\n│   ├── newnotebookcontroller.h/.cpp   # New notebook dialog controller\n│   ├── opennotebookcontroller.h/.cpp  # Open notebook flow controller\n│   ├── managenotebookscontroller.h/.cpp\n│   ├── importfoldercontroller.h/.cpp\n│   ├── recyclebincontroller.h/.cpp\n│   └── viewareacontroller.h/.cpp      # View area orchestrator (open/close/split/move)\n├── widgets/                # UI widgets (views receiving ServiceLocator&)\n│   ├── mainwindow2.h/.cpp  # Main window shell\n│   ├── notebookexplorer2.h/.cpp\n│   ├── notebookselector2.h/.cpp\n│   ├── toolbarhelper2.h/.cpp\n│   ├── viewwindow2.h/.cpp  # Abstract base for file viewer windows\n│   ├── markdownviewwindow2.h/.cpp  # Markdown editor/preview window\n│   ├── textviewwindow2.h/.cpp      # Plain text editor window\n│   ├── pdfviewwindow2.h/.cpp       # PDF viewer window\n│   ├── mindmapviewwindow2.h/.cpp   # Mind map viewer window\n│   ├── widgetviewwindow2.h/.cpp    # Generic widget-hosting window\n│   ├── viewsplit2.h/.cpp   # QTabWidget-based split pane (one vxcore workspace)\n│   ├── viewarea2.h/.cpp    # Splitter tree view (manages ViewSplit2 layout)\n│   └── dialogs/            # Dialog widgets\n│       ├── newnotedialog2.h/.cpp\n│       ├── newfolderdialog2.h/.cpp\n│       ├── newnotebookdialog2.h/.cpp\n│       ├── managenotebooksdialog2.h/.cpp\n│       └── importfolderdialog2.h/.cpp\n├── net/\n│   └── networkutils.h/.cpp # core_net: Qt Core/Network-only HTTP helpers\n│                           # (vnotex::NetworkUtils / NetworkReply / NetworkAccess)\n├── utils/\n│   └── fileutils2.h/.cpp   # File utilities\n└── ...\n```\n\n---\n\n## Source-Wide Qt Patterns\n\n### Memory Management\n```cpp\n// Use Qt smart pointers\nQScopedPointer<MainConfig> m_config;\nQSharedPointer<Task> task;\n\n// QObject parent-child for automatic cleanup\nm_themeMgr = new ThemeMgr(this);  // 'this' takes ownership\n```\n\n### Queued-Connection Metatypes on Qt 6\n\nQueued calls need copyable argument types known to Qt's metatype system. Keep\n`Q_DECLARE_METATYPE` and the existing typed `qRegisterMetaType<T>()` registrations for\ncustom values crossing threads. Qt 6's moc-generated signal metadata supplies parameter\ntypes directly; an unqualified signal spelling alone does **not** require an extra\n`qRegisterMetaType<T>(\"T\")` alias.\n\nString-based APIs still need their exact runtime type names. In particular, the existing\n`QMetaObject::invokeMethod(worker, \"method\", Qt::QueuedConnection, Q_ARG(T, value))` form\nmust resolve the spelling stringified by `Q_ARG`. If `Q_DECLARE_METATYPE(vnotex::T)`\nregisters the qualified name but `Q_ARG` spells `T`, retain the unqualified alias as well.\n\n`ImageHostService` registers `\"ImageHostWorkItem\"` for its `doUpload`, `doRemove` and\n`doTestConfig` worker dispatches. Keep that registration; real worker delivery is\ncovered by `tests/core/test_imagehostasync.cpp`.\nThe typed `SearchWorker` result/error and `ImageHostWorker` completion connections need\nno extra unqualified aliases. Real queued search delivery is covered by\n`testSimpleSearchUnicodeReplacement` and `testSearchContentStreamingBatchUnion` in\n`tests/core/test_searchservice.cpp`.\n\nDo not add aliases to every `Q_DECLARE_METATYPE` indiscriminately: inspect the actual\nstring-based callers, if any, before adding or removing a named registration.\n\n---\n\n## Related Modules\n\n| Module | File | Read this when |\n|--------|------|----------------|\n| Core & Services | [core/AGENTS.md](core/AGENTS.md) | ServiceLocator, DI, Buffer2, hooks, config, adding a service |\n| Services (deep) | [core/services/AGENTS.md](core/services/AGENTS.md) | Sync, save/search threading, update check, service internals |\n| Controllers | [controllers/AGENTS.md](controllers/AGENTS.md) | Adding or changing a controller |\n| Models | [models/AGENTS.md](models/AGENTS.md) | Qt Model/View data representations |\n| Views | [views/AGENTS.md](views/AGENTS.md) | View conventions, delegate patterns |\n| Widgets | [widgets/AGENTS.md](widgets/AGENTS.md) | Widget conventions, ViewArea2 framework, styling |\n| GUI Services | [gui/AGENTS.md](gui/AGENTS.md) | Theme, ViewWindowFactory, GUI utilities |\n| Utilities | [utils/AGENTS.md](utils/AGENTS.md) | PathUtils, HtmlUtils, FileUtils2 |\n| CI & Packaging | [../.github/AGENTS.md](../.github/AGENTS.md) | `src/Packaging.cmake`, Qt 6 deployment |\n","AGENTS.md":"# VNote Agent Development Guide\n\nRoot routing document: prerequisites, build, repo-wide rules, and an index of the module docs.\nModule-specific detail lives in the child `AGENTS.md` that owns the code — see\n[Module Documentation Index](#module-documentation-index).\n\n## Prerequisites\n\n- **Git** (with Git Bash on Windows)\n- **CMake** 3.21+\n- **Qt** 6.9+ (with QtWebEngine and Core5Compat)\n- **C++20-capable compiler** (MSVC, GCC, Clang) for the syntax indexer; VNote uses C++17\n- **Python 3** for syntax catalog generation\n- **clang-format** (optional, for automatic code formatting)\n\n## Setup\n\nAfter cloning the repository, run the init script:\n\n| Platform | Command |\n|----------|---------|\n| Linux/macOS | `bash scripts/init.sh` |\n| Windows | `scripts\\init.cmd` |\n\nThe init script:\n1. Initializes and updates git submodules recursively\n2. Installs pre-commit hook for automatic clang-format on staged C++ files\n3. Sets up vtextedit submodule pre-commit hook\n\n## Build Commands\n\n### Release Build\n```bash\nmkdir build && cd build\ncmake ..\ncmake --build . --config Release\n```\n\n### Debug Build\n```bash\nmkdir build && cd build\ncmake .. -DCMAKE_BUILD_TYPE=Debug\ncmake --build . --config Debug\n```\n\n### Windows (PowerShell)\n```powershell\nNew-Item -ItemType Directory -Force -Path build\nSet-Location build\ncmake .. -GNinja\ncmake --build . --config Release\n```\n\n### Clean Build\n```bash\nrm -rf build && mkdir build && cd build && cmake .. && cmake --build .\n```\nNever repair a stale build dir in place.\n\n### Building the Tests\nTests are **not** built by default (`VNOTE_BUILD_TESTS=OFF`), so a plain `cmake ..` produces the\napp only. CI enables them explicitly. To build them locally:\n```bash\ncmake .. -DVNOTE_BUILD_TESTS=ON\ncmake --build .\n```\n\nTesting (two suites, two build dirs): [tests/AGENTS.md](tests/AGENTS.md).\n\n### Pre-push verification (REQUIRED)\n\nBefore pushing parent-repository or submodule changes, agents MUST:\n\n1. Determine the affected build and test targets from **all unpushed changes** relative\n   to the destination branch, not just the latest commit. Trace shared headers/APIs,\n   build configuration and submodule pins to dependent consumers; filenames alone\n   are not sufficient. Broaden coverage when the impact cannot be bounded safely.\n2. Successfully build the affected application/library targets and **all test targets**\n   with tests enabled, using a compatible Qt kit, compiler and build configuration.\n   Include targets excluded from the default build and native-editor or standalone\n   submodule test targets in their own build directories. An app-only build does not\n   verify tests. Follow [tests/AGENTS.md](tests/AGENTS.md) for test build configuration.\n3. Verify the final code/configuration being pushed; rebuild required targets after\n   subsequent changes. Report the scope rationale, build commands, results, skips and\n   platform limits. If all unpushed changes are documentation-only, check the docs and\n   state that no build/test targets are affected; compilation and tests are not required.\n\n**Running tests is not required before pushing.** Successful compilation and linking of\nall test targets is sufficient for the local test verification gate; CI test requirements\nremain unchanged.\n\n**Do not push when required builds fail, time out or cannot run.** Fix the cause and\nverify again; never disable tests or exclude required targets just to obtain a successful\nbuild. If build verification is unavailable, report the exact blocker and obtain an\nexplicit user exception before pushing. A local build is not proof that tests pass or\nthat other operating systems or packaging gates pass; disclose that limitation.\n\n### CI runner debugging (SSH)\n\nWhen a failure needs the hosted runner, follow the key-restricted SSH procedure in\n[CI debugging](.github/AGENTS.md#interactive-ci-debugging). Dispatch a non-publishing\nbranch at the intended revision, preserve first-run evidence, and confirm session closure.\nAn ad-hoc debug pass is not proof that normal CI is fixed.\n\n---\n\n## Submodule Push Discipline (CRITICAL — read before every push)\n\nVNote pins git submodules (`libs/vxcore`, `libs/vtextedit`, `libs/QHotkey`, `libs/qwindowkit`)\nto specific commits. **CI clones submodules from their own remotes**, so a commit that exists\nonly locally fails every CI job at \"Init Submodules\" (`upload-pack: not our ref <sha>`) before\nany build runs.\n\n### Rule: ALWAYS push the submodule FIRST, then the parent repo.\n\n```bash\ncd libs/vxcore\ngit push origin HEAD:main      # or the appropriate branch\ncd ../..\ngit push                       # only now may the gitlink be pushed\n```\nTo push both at once with verification, use `git push --recurse-submodules=on-demand`, or enforce\nit once per clone:\n\n```bash\ngit config push.recurseSubmodules check   # aborts the parent push if a submodule commit is unpushed\n```\n\n### Before pushing, verify no submodule commit is stranded\n\n```bash\n# For each submodule, confirm local HEAD is not ahead of its remote:\ngit submodule foreach 'git status -sb'\n# A line like \"## main...origin/main [ahead 1]\" means an UNPUSHED submodule commit — push it before pushing vnote.\n\n# Confirm the pinned SHA exists on the submodule remote:\ncd libs/vxcore && git branch -r --contains $(git rev-parse HEAD) && cd ../..\n# Empty output = the commit is NOT on any remote branch yet. DO NOT push vnote until it is.\n```\n\nIf CI is already failing with \"not our ref\", the fix is to push the missing submodule commit (do\n**not** roll back the parent pointer if newer parent commits depend on the new submodule API).\n\n### cmark is vendored twice — bump both pins together\n\nThe same cmark fork (`https://github.com/vnotex/cmark.git`) is pinned by two different submodules:\n\n| Parent submodule | Nested cmark path |\n|---|---|\n| `libs/vtextedit` | `libs/cmark` |\n| `libs/vxcore` | `third_party/cmark` |\n\nOnly **one** is ever compiled in: `libs/CMakeLists.txt` adds `vtextedit` first, which defines the\n`cmark` target unconditionally, so vxcore's `if(NOT TARGET cmark)` guard skips its own copy.\nDiverged pins silently build vxcore against an untested cmark, with no error or warning.\n**Bump both submodules to the same cmark commit in a single change.** [`tests/utils/test_cmark_pin_drift.cpp`](tests/utils/test_cmark_pin_drift.cpp) fails the\nbuild when the two recorded pins differ.\n\n---\n\n## Architecture Overview\n\nVNote uses a **clean architecture** with **Model-View-Controller (MVC)** pattern and dependency\ninjection. Models hold data, Views display it, Controllers handle logic, Services own domain\noperations, and every layer receives a `ServiceLocator&` — there are no singletons.\n\n| Layer | Location | Responsibility | Example |\n|-------|----------|----------------|---------|\n| **Model** | `src/models/` | Data representation, Qt Model/View integration | `NotebookNodeModel` exposes node hierarchy via `QAbstractItemModel` |\n| **View** | `src/views/` | Display data, capture user input, emit signals | `NotebookNodeView` renders tree, emits `nodeActivated` signal |\n| **Controller** | `src/controllers/` | Handle actions, orchestrate Model/View, business logic | `NotebookNodeController` handles new/delete/rename operations |\n| **Service** | `src/core/services/` | Domain operations, data access via vxcore | `NotebookCoreService` wraps vxcore C API for notebook CRUD |\n\n### MVC Rules (MUST FOLLOW)\n\n| Rule | Rationale |\n|------|-----------|\n| **Models MUST NOT** contain UI logic | Models are reusable across different views |\n| **Views MUST NOT** modify data directly | Views only display and emit signals |\n| **Controllers MUST NOT** inherit from QWidget | Controllers are testable without GUI |\n| **All layers receive `ServiceLocator&`** | Enables dependency injection and testing |\n| **Use signals/slots between layers** | Loose coupling between M, V, C |\n\nFull diagram, directory tree, design-decision rationale (including the ViewArea2 framework), and\nsource-wide Qt patterns: see [src/AGENTS.md](src/AGENTS.md).\n\n---\n\n## Code Style Guidelines\n\n### Standards\n- **C++17** standard\n- **Qt 6** framework\n- CMake with `CMAKE_AUTOMOC`, `CMAKE_AUTOUIC`, `CMAKE_AUTORCC` enabled\n\n### Formatting\n- 2-space indentation\n- 100 character line limit\n- Pointer alignment right: `int *ptr`, not `int* ptr`\n- Use provided `.clang-format` (auto-applied via pre-commit hook)\n\n### No Hardcoded Colors (enforced)\n\n**Never write a literal color into a `setStyleSheet()` call.** VNote ships 12\nthemes, 6 of them dark; a hardcoded `#RRGGBB`, `rgb()/rgba()` literal, or CSS\ncolor name is correct only in whichever theme its author was running, and it\ncannot follow a runtime theme switch.\n\n`tests/utils/test_hardcoded_color_drift.cpp` is a grep gate over `src/` that\nfails the build on any **stylesheet string** literal containing both a CSS color\nproperty and a literal color value (colors used as *data*, and `QColor` painted\nin a `paintEvent`, are out of scope).\n\nUse `InlineBanner`, the `SeverityText` / `MutedText` dynamic properties, a rule\nin each theme's `interface.qss`, or `ThemeService::paletteColor()`. Do **not**\nuse `setEnabled(false)` to mute text. See\n[src/widgets/AGENTS.md § No Hardcoded Colors in C++](src/widgets/AGENTS.md#no-hardcoded-colors-in-c)\nfor the decision table and the escape hatch.\n\n### Tooltip Copy\n\nTooltip text MUST NOT end with sentence-ending punctuation. Keep punctuation needed between\nsentences or clauses; write parenthetical qualifiers such as `(restart required)` in lowercase\nwithout terminal punctuation.\n\n### Naming Conventions\n\n| Element | Convention | Example |\n|---------|------------|---------|\n| Classes | CamelCase | `ConfigMgr`, `MainWindow` |\n| Methods | camelCase | `getInst()`, `initLoad()` |\n| Parameters | `p_` prefix | `p_parent`, `p_config` |\n| Members | `m_` prefix | `m_themeMgr`, `m_config` |\n| Constants | `c_` prefix | `c_orgName`, `c_appName` |\n| Getters | `get` prefix | `getThemeMgr()`, `getName()` |\n\n### Include Order\n```cpp\n#include \"ownheader.h\"      // Own header first\n\n#include <QDateTime>        // Qt includes\n#include <QObject>\n\n#include \"localheader.h\"    // Local includes\n#include <core/configmgr.h>\n#include <utils/utils.h>\n\nusing namespace vnotex;     // Namespace declaration in .cpp\n```\n\n### Header Guards\n```cpp\n#ifndef CLASSNAME_H\n#define CLASSNAME_H\n// ...\n#endif // CLASSNAME_H\n```\n\n### Namespaces\n\nVNote uses a single `vnotex` namespace. Services that wrap the vxcore C library use the `CoreService` suffix to distinguish them from higher-level wrapper services:\n\n| Class Pattern | Purpose | Examples |\n|---------------|---------|----------|\n| `XXXCoreService` | Low-level services that wrap the vxcore C library (hold `VxCoreContextHandle`) | `ConfigCoreService`, `NotebookCoreService`, `BufferCoreService`, `SearchCoreService`, `FileTypeCoreService` |\n| Other classes | Everything else: UI, controllers, models, hook-aware wrapper services | `BufferService` (hook wrapper), `HookManager`, `TemplateService`, `ConfigMgr2`, controllers, widgets |\n\n**Rules:**\n- `using namespace vnotex;` in `.cpp` files only, never in headers\n- Forward declarations preferred in headers\n\n### Signal/Slot Connections\n```cpp\n// Preferred: typed signal/slot syntax\nconnect(m_taskMgr, &TaskMgr::taskOutputRequested,\n        this, &VNoteX::showOutputRequested);\n\n// With overloaded methods\nconnect(this, &VNoteX::openNodeRequested, m_bufferMgr,\n        QOverload<Node *, const QSharedPointer<FileOpenParameters> &>::of(&BufferMgr::open));\n```\n\nMemory management, queued-connection metatype registration, and the rest of\nthe source-wide patterns: [src/AGENTS.md § Source-Wide Qt Patterns](src/AGENTS.md#source-wide-qt-patterns).\nNoncopyable, `VNOTEX_DEPRECATED` and exception handling: [src/core/AGENTS.md](src/core/AGENTS.md#core-c-facilities).\n\n---\n\n## Sync State Model\n\nNotebook sync has 8 reachable states (S0-S7), defined by the tuple of on-disk JSON sync fields,\ncredential presence in the OS keychain (Git PAT or WebDAV login), and runtime registration in vxcore's `states_` map. **S5 is the\nonly \"ready\" state**; S1-S4 and S6 are partial/inconsistent, S0 is cleanly disabled, S7 is\nin-flight. Every controller, widget, and service that touches sync must reason in these terms.\n\nFull predicate table, recovery paths, reconcile semantics, disable cleanup, the S6 startup sweep,\nand the Qt-side scheduling shape:\n[src/core/services/AGENTS.md § Sync State Model](src/core/services/AGENTS.md#sync-state-model).\nvxcore-side threading contract:\n[libs/vxcore/src/sync/AGENTS.md](libs/vxcore/src/sync/AGENTS.md).\n---\n\n## Save Path Threading Contract\n\nBuffer saves run on a worker via `BufferSaveQueue`; save and git-stage/commit work on the SAME\nnotebook are serialized by the per-notebook `NotebookIoGate` async mutex. WebDAV additionally installs incoming changes under\nthe gate after a GUI-owned buffer reservation and save drain; metadata refresh remains GUI-thread-only.\n\n> **Forbidden Patterns (post-T7):**\n> - Calling `vxcore_buffer_save` directly from the UI thread. Use [`BufferSaveQueue::enqueue`](src/core/services/buffersavequeue.h) instead.\n> - Touching a notebook's working tree (save, stage, commit, checkout) without holding `NotebookIoGate::ScopedLock(notebookId)`.\n\nFull rationale and the two-phase sync gate:\n[src/core/services/AGENTS.md § Save Path Threading Contract](src/core/services/AGENTS.md#save-path-threading-contract).\n\n---\n\n## Cross-Notebook Node Transfer\n\nCross-notebook copy and move operations must go through\n[`NodeTransferService`](src/core/services/nodetransferservice.h). VNote owns editor/comment\ndurability, sync exclusion, IO-gate ordering, hooks, clipboard policy, progress, and UI feedback;\nvxcore owns snapshotting, identity regeneration, storage transactions, recovery, and mutation facts.\nControllers and widgets must not call the vxcore transfer API directly. Full orchestration contract:\n[src/core/services/AGENTS.md § Cross-Notebook Node Transfer](src/core/services/AGENTS.md#cross-notebook-node-transfer).\n\n---\n\n## Search Threading Contract\n\nContent search in vxcore owns NO thread pool: it enqueues one work item per file-chunk onto the\n`\"vxcore.search\"` work queue, and the CALLER owns the drain policy (VNote's `SearchService` runs\nthe drain pool; the initiating thread help-drains, which is the single-threaded correctness\nfloor).\n\nFull contract: [src/core/services/AGENTS.md § Search Threading Contract](src/core/services/AGENTS.md#search-threading-contract).\n\n---\n\n## Update Check\n\nOrdinary startup and manual checks fetch release metadata and offer the **release page**.\n`UpdateService` and `UpdateInfo` remain check-only: no asset selection, download, verification\nor installation is added to them.\n\n> **An ordinary check never modifies the install directory or downloads release artifacts.**\n> It creates no lease, staging tree or journal, and extracts or executes nothing. Its only\n> writes are the `lastUpdateCheckTime` / `skippedUpdateVersion` config values. Startup must\n> remain usable from read-only installations (`/usr/bin`, Program Files, a read-only DMG;\n> issue #2728), without probing install-directory writability.\n\nThe sole installation exception is an **explicit Windows “Update Now” notification action**\nlaunching the deployed external PowerShell script. Keep “Check Release” first and available;\nthe manual-check dialog is unchanged and has no automatic-install entry point. The action\nmust disclose downloading, closing and reopening VNote; checks never activate it themselves.\n\nThe external updater must authenticate the offered version's signed full-package manifest\nwith the bundled minisign verifier before using it, then verify the archive and payload.\nIt consumes no deltas, never falls back to another source or unsigned artifacts, and requests\nshutdown only after preparation succeeds. A launch-scoped, PID/token-authenticated pipe and\nan explicit accepted close authorize installation; cancellation or pipe EOF does not. Wait\nfor the original process to exit, including save/sync drains, without force-killing it.\n\nPreserve portable `config` and all unowned files by cloning the stopped installation and\noverlaying verified payload files, then swapping sibling directories. Reject path/reparse\nhazards and incompatible collisions; retain a complete recovery backup and restore it if\nthe second rename fails. Protected installations require manual-update guidance, **never\nelevation, ACL changes or dependency installation**. Reopen with the helper's inherited token;\nreport installed-but-not-reopened separately. Two renames are not power-loss atomic.\n\nRepo-wide forbidden patterns (also constrain CI, packaging, controllers and widgets):\n\n- **Never** download, extract, execute or install a release artifact during an ordinary check.\n- **Never** write outside the configuration directory as part of an update check.\n- **Never** read `assets[]` in `UpdateService`; only the explicit external updater selects assets.\n- **Never** give `UpdateService` a `ConfigMgr2` dependency — add policy to the controller.\n- **Never** restore the C++ installer, delta application or a general-purpose quit CLI/protocol.\n\nThe Windows release publishes a signed full-package manifest and may also publish\nunused delta ZIPs. Signing and external-verifier contract: [docs/update-signing.md](docs/update-signing.md).\nController launch/shutdown ownership: [src/controllers/AGENTS.md § Update Check](src/controllers/AGENTS.md#update-check).\nEndpoints, source-scoped redirects and check threading:\n[src/core/services/AGENTS.md § Update Check](src/core/services/AGENTS.md#update-check).\n\n---\n\n## Logging\n\nUse Qt logging macros:\n```cpp\nqDebug() << \"Debug message\";\nqInfo() << \"Info message\";\nqWarning() << \"Warning message\";\nqCritical() << \"Critical error\";\n```\n\n## Code Formatting\n\nThe pre-commit hook automatically formats staged C++ files using clang-format.\n\n**Manual formatting:**\n```bash\nclang-format -i src/core/myfile.cpp\n```\n\n**Excluded from formatting:** `libs/` directory (third-party code)\n\n### Format before staging and committing\n\nAgents MUST format intended C++ changes with the repository configuration and the same formatter\nas the pre-commit hook **before staging**. Review the resulting diff and preserve unrelated edits.\nPrefer explicit `git add -- <paths>` followed by ordinary `git commit` when the index contains only\nintended changes. If other work must remain staged, a path-limited commit (`git commit --only` or\n`git commit <paths>`) must not rely on the hook for further formatting: verify the formatter would\nmake no additional changes first.\n\nPath-limited commits use a temporary index. The hook's `clang-format -i` and `git add` can update\nthe working file and committed snapshot while leaving the normal index with older formatting.\nAfter such a commit, compare HEAD, index and working tree for the committed paths; do not blindly\nclassify `MM` leftovers as user edits and keep restoring them.\n\nClean up only residue proven to come from the agent's workflow: when `git diff HEAD -- <path>` is\nempty and staged/unstaged formatting hunks are exact inverses, `git restore --staged -- <path>`\nresynchronizes the index without changing the working file. An empty HEAD diff alone does not\njustify clearing intentional user staging. Never use blanket reset/restore or stash for cleanup.\n\n---\n\n## Shared JSON Keys (SSOT)\n\nCross-boundary JSON keys (vxcore↔Qt) live in `<vxcore/notebook_json_keys.h>`.\nSee [`libs/vxcore/AGENTS.md` § JSON Conventions](libs/vxcore/AGENTS.md#json-conventions)\nfor the SSOT contract and the `test_json_key_drift` regression gate.\n\n---\n\n## Module Documentation Index\n\nDetailed knowledge for each module lives in its own AGENTS.md.\n\n**Where to write new documentation:** default to the child `AGENTS.md` that owns the code (create\none for the directory if it does not exist yet). The root doc is injected into *every* agent turn,\nso anything added here costs context on turns that will never need it. Add to root only when the\nknowledge is genuinely repo-wide — i.e. it constrains callers who will never load the owning\nmodule's doc (as the MVC rules and the update-install invariant do), or it is a build/setup/style\nrule that applies everywhere. Even then, keep root to a short normative summary plus a link, and\nput the full detail in the module doc.\n\n| Module | File | Read this when |\n|--------|------|----------------|\n| Source overview | [src/AGENTS.md](src/AGENTS.md) | You need the architecture diagram, directory tree, design-decision rationale, or a source-wide Qt pattern (memory, queued metatypes) |\n| Core & Services | [src/core/AGENTS.md](src/core/AGENTS.md) | ServiceLocator, DI, Buffer2, hooks, config, themes, adding a service |\n| Services (deep) | [src/core/services/AGENTS.md](src/core/services/AGENTS.md) | Sync state model, save/search threading, update check, notifications |\n| Controllers | [src/controllers/AGENTS.md](src/controllers/AGENTS.md) | Adding or changing a controller; MVC rules for controllers |\n| Models | [src/models/AGENTS.md](src/models/AGENTS.md) | Qt Model/View data representations |\n| Views | [src/views/AGENTS.md](src/views/AGENTS.md) | View conventions, delegate patterns |\n| Widgets | [src/widgets/AGENTS.md](src/widgets/AGENTS.md) | Widget conventions, ViewArea2 framework, styling, construction pattern |\n| GUI Services | [src/gui/AGENTS.md](src/gui/AGENTS.md) | Theme, ViewWindowFactory, GUI utilities |\n| Utilities | [src/utils/AGENTS.md](src/utils/AGENTS.md) | PathUtils, HtmlUtils, FileUtils2 reference |\n| pdf.js assets | [src/data/extra/web/pdf.js/AGENTS.md](src/data/extra/web/pdf.js/AGENTS.md) | Touching the vendored PDF viewer or its Qt 6.9+ WebEngine requirement |\n| Testing | [tests/AGENTS.md](tests/AGENTS.md) | Agent GUI smoke testing with `--no-single-instance`, test suites, test mode, fixtures, coverage |\n| CI & Packaging | [.github/AGENTS.md](.github/AGENTS.md) | Workflows, SSH runner debugging, `src/Packaging.cmake`, Qt 6 release packages |\n| vxcore (submodule) | [libs/vxcore/AGENTS.md](libs/vxcore/AGENTS.md) | C library: notebook/config/search backend |\n| vxcore Sync | [libs/vxcore/src/sync/AGENTS.md](libs/vxcore/src/sync/AGENTS.md) | Pluggable sync backend interface (ISyncBackend, SyncManager) |\n| vtextedit (submodule) | [libs/vtextedit/AGENTS.md](libs/vtextedit/AGENTS.md) | Qt editor widget library |\n",".github/AGENTS.md":"# CI, Packaging & Release\n\nThis directory holds the GitHub Actions workflows. **This document also governs\n[`src/Packaging.cmake`](../src/Packaging.cmake)**, which lives outside this directory and\ntherefore will not auto-load this file — open it explicitly when touching packaging, the\nWindows Qt 6 packages, or updater artifacts.\n\nSee also: [../AGENTS.md](../AGENTS.md) (repo-wide rules, submodule push discipline) and\n[../src/AGENTS.md](../src/AGENTS.md) (architecture, source-wide Qt patterns).\n\n---\n\n## Build execution\n\nLinux and macOS set `CMAKE_BUILD_PARALLEL_LEVEL=2` for both packaging and test\nbuilds; Make otherwise builds these large targets serially. Keep Cargo's separate\ntwo-job limit. In test-enabled jobs, run both parent and native-editor suites even when one fails,\nthen fail the step if either returned nonzero. Preserve both original CTest logs\nand the Typst JUnit reports before diagnostics; macOS artifact names include the Qt kit.\nUpload reports for successful test steps too: a QtTest case can skip inside an otherwise\npassing executable, so a green CTest result alone does not prove native input was exercised.\nDiagnostic reruns require the test step itself to have failed and a nonempty\n`Testing/Temporary/LastTestsFailed.log` in that suite's build tree. Parent and\nnative-editor histories are independent: `ctest --rerun-failed` without its own\nfailure history can rerun a passing suite after an unrelated failure.\n\nLinux, Windows and macOS allow 180 minutes for cold builds, full native test suites,\npackage gates and artifact publication. Keep the individual CTest deadlines; the job\nbudget is not a test timeout.\nmacOS creates and unlocks its disposable test keychain only after test compilation,\nright before running CTest. Cold builds must not consume the vault's unlock lease;\nrestore the original keychain before final packaging and signing.\n\nThe macOS matrix enables unit/native GUI tests only for Qt 6.10.3. Qt 6.9.3 sets\n`VNOTE_BUILD_TESTS=OFF` and skips the test build, execution and disposable test keychain.\nThe existing `[Release]` condition disables these suites for both kits. Both Qt kits still\nbuild and sign packages and run the signed-package ABI, consumer and WebEngine gates,\nincluding native Intel verification; do not describe Qt 6.9.3 as unit-test validated.\n\n## Interactive CI debugging\n\nUse this when a failure needs the real hosted OS, desktop, Qt kit or CI setup.\n`workflow_dispatch` accepts `debug_enabled=true` plus a single `debug_public_key`\n(`ssh-ed25519`). `.github/scripts/debug-session.py` uses pinned Upterm 0.39.0 and the\npinned `uptermd.upterm.dev` host-certificate authority. There is no web-shell or\nanonymous fallback. Never put a private key into inputs, logs or artifacts.\n\n### Dispatch from the local checkout\n\nThe examples use PowerShell 7.3+, an authenticated GitHub CLI, OpenSSH and Python.\nStart at the repository root, at the committed revision you intend to investigate.\nCreate a disposable key outside the checkout; keep diagnostic evidence outside this\nkey directory so it survives key cleanup:\n\n```powershell\n$debugDir = Join-Path ([IO.Path]::GetTempPath()) ('vnote-ci-ssh-' + [guid]::NewGuid().ToString('N'))\nNew-Item -ItemType Directory -Path $debugDir | Out-Null\n$key = Join-Path $debugDir 'id_ed25519'\nssh-keygen -q -t ed25519 -N '' -C vnote-ci-debug -f $key\nif ($LASTEXITCODE -ne 0) { throw 'Key generation failed' }\n$publicKey = (Get-Content -LiteralPath \"$key.pub\" -Raw).Trim()\n```\n\nUse a non-`master` branch at that revision. Debugging on `master` can resume into\ncontinuous-release publication with ad-hoc runner edits; debug mode does not disable\npublication. Follow the submodule push discipline before publishing the branch:\n\n```powershell\n$debugBranch = 'ci-debug-' + (git rev-parse --short HEAD).Trim()\ngit push --recurse-submodules=check origin \"HEAD:refs/heads/$debugBranch\"\nif ($LASTEXITCODE -ne 0) { throw 'Debug branch push failed' }\ngh workflow run ci-win.yml --ref $debugBranch `\n  -f debug_enabled=true -f \"debug_public_key=$publicKey\"\nif ($LASTEXITCODE -ne 0) { throw 'Debug dispatch failed' }\n```\n\nChoose the workflow and connection artifact for the required platform:\n\n| Workflow | Connection artifact | Pause point |\n|----------|---------------------|-------------|\n| `ci-win.yml` | `debug-connection-windows` | After tools, before configure/build |\n| `ci-linux.yml` | `debug-connection-linux` | After configure, before build and test dependencies |\n| `ci-macos.yml` | `debug-connection-macos-<Qt version>` | After configure, before test build/keychain setup |\n\nmacOS build jobs also use the `Mac-code-sign` environment. Any configured approval or\nbranch policy applies before SSH setup; inspect a waiting/blocked job before treating\na missing connection artifact as a helper failure. Do not switch to `master` to bypass\nan environment restriction.\n\nSet `$runId` to the ID in the dispatched run URL. If the CLI did not return a URL,\nuse `gh run list --workflow ci-win.yml --branch $debugBranch --event workflow_dispatch`\nand match the intended revision, not an unrelated latest run. Confirm `headSha` and\n`headBranch` with `gh run view $runId --json headSha,headBranch,jobs`.\n\nDownload once the connection-upload step succeeds; the artifact is available while\nthe job is still running. Do not wait for workflow completion to obtain it. Each\nmacOS matrix job has its own session/artifact, including the Qt 6.9 job whose unit\nsuite is disabled. Account for every paused job. No guest within 15 minutes ends a\nsession; the normal job deadline also continues to run.\n\n```powershell\n$connectionDir = Join-Path $debugDir 'connection'\ngh run download $runId --name debug-connection-windows --dir $connectionDir\nif ($LASTEXITCODE -ne 0) { throw 'Connection artifact download failed' }\n$connection = Get-Content (Join-Path $connectionDir 'connection.json') -Raw | ConvertFrom-Json\nssh-keygen -lf \"$key.pub\" -E sha256\n$connection.keyFingerprint\n```\n\nCompare the `SHA256:` fingerprint tokens, not the entire lines: the key comments can\ndiffer. The artifact contains `sshCommand`, `joinCommand`, `keyFingerprint` and\n`restoreEnvironment`; it contains no private key. Use fresh metadata for this run,\nnot an expired session address from an old log.\n\n### Connect with the pinned relay authority\n\nCreate a dedicated `known_hosts` from the checked-in helper's pin rather than trusting\n`ssh-keyscan`, accepting a new host key interactively, or disabling host-key checks.\n`runpy` below loads the definitions without invoking the helper's guarded `main()`:\n\n```powershell\n$knownHosts = (Join-Path $debugDir 'known_hosts').Replace('\\', '/')\npython -c \"import runpy,sys;sys.stdout.write(runpy.run_path('.github/scripts/debug-session.py')['KNOWN_HOSTS'])\" |\n  Set-Content -LiteralPath $knownHosts -Encoding ascii\nif ($LASTEXITCODE -ne 0) { throw 'Relay pin extraction failed' }\nif ($connection.sshCommand -notmatch '^ssh ([A-Za-z0-9_-]+@uptermd\\.upterm\\.dev)$') {\n  throw 'Unexpected SSH command; inspect connection.json without executing its contents'\n}\n$destination = $Matches[1]\n$sshArgs = @(\n  '-F', 'none', '-i', $key,\n  '-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes', '-o', 'IdentityAgent=none',\n  '-o', 'ForwardAgent=no', '-o', 'StrictHostKeyChecking=yes',\n  '-o', ('UserKnownHostsFile=\"' + $knownHosts + '\"'), '-o', 'GlobalKnownHostsFile=none'\n)\nssh -tt @sshArgs $destination\n```\n\nThis attaches to the hosted shell (`pwsh -NoProfile` on Windows, clean Bash elsewhere),\ninitially in `GITHUB_WORKSPACE`. For agent automation, use a named PTY session and\nsend commands to that shell; wait for an explicit completion marker and exit code.\nA live PID, quiet terminal, or SSH disconnect does not prove command completion.\n\n### Restore the environment and reproduce the workflow\n\nThe helper removes checkout/submodule HTTP auth and credential-bearing URL rewrites,\nscrubs token/secret/key variables from the hosted process, and retains runner orphan\ntracking. Do not inject credentials back into the session to bypass this isolation.\nArtifact/signing steps keep their normal environment and run after session cleanup.\n\nUpterm redirects three XDG variables for its own state. Before measuring application\nbehavior, restore the original values in `connection.json`'s `restoreEnvironment`;\nJSON `null` means unset, not an empty or literal `null` value. On the Windows runner:\n\n```powershell\n$metadata = Join-Path $env:RUNNER_TEMP 'vnote-debug/connection.json'\n$original = (Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json).restoreEnvironment\nforeach ($entry in $original.PSObject.Properties) {\n  if ($null -eq $entry.Value) {\n    Remove-Item -LiteralPath ('Env:' + $entry.Name) -ErrorAction SilentlyContinue\n  } else {\n    [Environment]::SetEnvironmentVariable($entry.Name, $entry.Value, 'Process')\n  }\n}\n$build = Join-Path (Split-Path $env:GITHUB_WORKSPACE -Parent) 'build'\n```\n\nOn Bash, use `unset` for null entries and `export` with the recorded values otherwise.\nRestore only the hosted shell's application environment, not the helper's state files.\n\nRead the checked-out workflow's configure/build/test steps and reproduce their commands,\nworking directories and **step-local environment**. The build directory is the sibling\n`build` of `GITHUB_WORKSPACE`, not a directory inside the checkout.\n\n- **Windows:** the debug shell is not a Visual Studio developer shell. The earlier\n  `vcvars64.bat` invocation affected only its own `cmd` process; `VS_CMD` and\n  `VNOTE_TESTS` from later steps are not already set. Use the matrix's toolchain and\n  import its environment into the hosted shell, or run the entire command chain in\n  an initialized `cmd`. Match Release/Ninja and `VNOTE_BUILD_TESTS=ON` for test work.\n- **Linux:** test dependencies, D-Bus/keyring setup and the Xvfb/native-platform wrapper\n  come after the pause. Reproduce the workflow and `.github/scripts/run-linux-tests.sh`,\n  rather than replacing native GUI tests with `offscreen` or `minimal`.\n- **macOS:** preserve the configured Qt kit/architectures, normalize `TMPDIR` as the\n  workflow does, and create/unlock the disposable test keychain after compilation.\n  The Qt 6.9 configuration does not register the unit/native suites.\n- **Package-dependent tests:** build/package/extract prerequisites in the original\n  order. For example, Windows updater fixtures need the packaged installation; a\n  target-only build is not equivalent to the full CI setup. A debug-shell-only failure\n  (including PowerShell module lookup) is not automatically the original regression.\n\n### Preserve evidence before rerunning\n\nIn the build directory, inspect `ctest --show-only=json-v1` for the executable, arguments, working\ndirectory and test environment. Build the relevant target, then run its registered\nCTest command without changing assertions, deadlines or platform behavior. A focused\nWindows example, after the workflow's configure/toolchain prerequisites:\n\n```powershell\ncmake --build $build --target test_themeservice\nif ($LASTEXITCODE -ne 0) { throw 'Theme test build failed' }\nctest --test-dir $build -C Release -R '^test_themeservice$' --output-on-failure --no-tests=error\n$testExit = $LASTEXITCODE\nWrite-Output \"DEBUG_CTEST_EXIT=$testExit\"\n```\n\nCapture the **first** failure to files before standalone repetitions or full-order\nexperiments overwrite it. Keep CTest's `Testing/Temporary/Last*.log`, QtTest text/JUnit\nreports, exit code, revision and relevant environment. The Windows theme registration\nalready writes `tests/gui/test_themeservice.txt` and `.xml` in the build tree. For other\nQtTest executables, use unique `-o <file>,txt` and `-o <file>,junitxml` outputs while\nretaining `-o -,txt` and the registered runtime environment. File loggers cannot explain\na failure before QtTest initializes. Inspect skips as well as pass counts.\n\nExport evidence while the session is reachable: from another local terminal with the\nsame client variables, use `sftp @sshArgs $destination` (without SSH's `-tt`) and download\nthe files to a separate evidence directory. Resolve remote paths with SFTP's `pwd`/`ls`\ninstead of guessing Windows drive mappings. Ad-hoc files are not automatically uploaded\nby the workflow; normal report-upload steps run only after the debug pause ends.\n\nA relay disconnect can leave the runner and build alive. Check the run's job state and\nreattach to the same session if still reachable; inspect the saved completion marker\nand files instead of restarting the build blindly. If standalone runs pass, compare\nnormal CI setup/order before proposing production or timing changes. Ad-hoc debug\npasses do not replace a clean CI run of the committed fix.\n\n### Close the session and remove the key\n\nRun `exit 0` **inside the hosted shell** to end it and resume the workflow. Closing the\nlocal SSH client alone merely disconnects; it does not end the pause. Close each macOS\nmatrix session you opened and account for any other paused matrix jobs.\n\nIf the relay cannot be recovered, cancel the job with `gh run cancel $runId`, then\nawait shutdown with `gh run watch $runId --exit-status` (cancellation returns nonzero).\nVerify the `Close SSH debug session` step succeeded via `gh run view $runId --json jobs`;\ndo not assume a lost connection means the exposed session is gone. Once closure is\nconfirmed and evidence is saved outside `$debugDir`, remove the local disposable key:\n\n```powershell\nRemove-Item -LiteralPath $debugDir -Recurse -Force\n```\n\n## Release packaging dependencies\n\n`pack` must build `cmark_exe` before CPack runs: upstream cmark installs its CLI\nunconditionally, even though Windows package pruning removes it afterward. Release\nCI deliberately skips `all` to avoid building tests, so do not rely on a prior\nall-target build or weaken the install/pruning checks to hide a missing executable.\n\n### Native Typst runtime and attribution\n\nVNote requires the pinned Rust 1.99.0/Typst 0.15.1 shared library and Qt PDF. Embedding forces\n`VTEXTEDIT_ENABLE_TYPST=ON` and `VTEXTEDIT_BUILD_DEMOS=OFF`; do not ship upstream demos.\n`vnote_typst_runtime` always refreshes the native runtime (and VTextEdit.dll on Windows),\neven without relinking VNote. Both `pack` and direct `deploy` depend on this target and\n`prepare_typst_notices`, retaining the existing cmark/lrelease prerequisites.\n\nProvision `cargo +1.99.0 install cargo-about --locked --version 0.9.2 --features cli` and\nfetch the backend's complete locked dependency graph before offline deployment. The `cli`\nfeature is required: installing this release without it produces no executable. Keep\n`CARGO_BUILD_JOBS=2` and OS/Rust/lockfile-specific Cargo caches; Apple keys also include\nthe deployment target. Non-release native editor tests run from `build/libs/vtextedit`\nwith `--no-tests=error`, not just the parent's CTest root.\n\n`package/prepare-typst-notices.py` uses `package/typst-about.toml` to generate full Rust\nlicense texts, including verbatim typst-assets/font notices and the backend LGPL text.\nMatching `sbom/qtpdf-<version>.spdx.json` and\n`sbom/qtwebengine-chromium-pdf-<version>.spdx.json` are required SDK attribution inputs.\nQt 6.9.3 provides both when qtpdf and qtwebengine are installed; the Chromium PDF inventory\nis in qtwebengine. Missing/empty inputs fail packaging; never substitute another Qt version.\nGeneration is deployment-only, with no dependency installation at configure or runtime.\nNotices ship in Windows `licenses/typst`, Linux `share/licenses/vnote/typst`, and macOS\n`Contents/Resources/licenses/typst`.\n\nWindows deployment scans both vnote and VTextEdit for Qt6Pdf and keeps\n`vtextedit_typst.dll` in the flat runtime directory, never the pruned lib/bin directories.\nLinux scans the installed `libvtextedit_typst.so.1` as a linuxdeploy library and checks\ninstall/deploy exit codes. Universal macOS libraries build both Rust triples with the job's\ndeployment target, then lipo once. Stage `libvtextedit_typst.1.dylib` explicitly in\n`Contents/Frameworks` through vnote_typst_runtime/deploy, fix load paths and sign before\ndeep bundle signing; ordinary install() does not assemble the VNote app. Resolve the\npackaged VTextEdit filename from\nVNote's linked dependency, not an unversioned dylib name; rpath cleanup and final DMG checks\nmust inspect that same library. Remove only rpaths outside the final DMG path allowlist.\nInspect both slices' minimum-OS metadata and reject build-tree dependencies. Metadata from a newer runner is not a Monterey launch test.\nRun `scripts/e2e/smoke_typst_package.py --library <extracted-library>` against final package\ncontents without developer Cargo/Qt/Typst overrides. It calls C ABI 1 in a supervised\nPython test worker and verifies real PDF/heading output and reply lifetime after session\ndestruction. Also launch the packaged consumer and inspect native dependency closure;\nctypes alone does not prove VTextEdit/VNote linkage. Reject the old Typst executable.\nOn macOS, architecture probes and ABI workers reuse the gate's already-running Python\n(`sys.executable`), not a different `/usr/bin/python3` runtime. Keep the 15-second architecture\nprobe, mandatory native-architecture execution, and existing non-host availability policy.\n`scripts/e2e/smoke_macos_consumer.py` requires the expected native architecture and launches\nVNote with the unchanged 30-second budget and an isolated home. Test-enabled builds run ARM\nnative suites; all builds retain both available ABI slices and the WebEngine gate. Separate\n`macos-15-intel` jobs download\nthe same signed DMGs and require native x86_64 ABI and consumer execution for both Qt kits.\nCold Rosetta translation of QtWebEngineCore can exceed the launch budget before main; do not\nwarm its cache, retry to green, or increase the deadline instead of using native Intel proof.\nStaged DMG artifacts are inputs to verification, not publication approval. Both continuous and\nversioned publication depend on all ARM build/gates and both Intel verification jobs, and\npublish the unchanged signed artifacts. Native macOS 15 Intel proof is not a Monterey test.\nA packaged-consumer timeout remains a failure. Capture bounded native stack/screen diagnostics\nunder `RUNNER_TEMP`, terminate only that unreaped probe's own process group, and retain the\nmatrix-specific diagnostic artifact. Include open-file, memory-map and bounded read-only\nRosetta-daemon samples to locate pre-main loader stalls; pre-create privileged sample output\nas the runner user. A separate one-day `failed-package-macos-<Qt version>` artifact retains\nonly the exact signed DMG for reproduction, never adjacent certificates/keychains or the build\ndirectory. It is diagnostic-only, not a release. Do not mask the timeout, force-unmount a busy\npackage, or move SSH sessions past signing-secret provisioning.\n\n## macOS runtime composition\n\nRun `package/prune-macos-package.cmake` with `VNOTE_APP_DIR` after each deployment\npass and after the last CI framework fixup, **before signing**. CPack's external\nmacOS packager rewrites the build bundle; Windows install-staging pruning does not\napply. Complete all test builds before final `pack`/deployment: those builds can relink\nVNote/VTextEdit and restore build-tree RPATHs. Both framework-fixup passes precede the\nfinal RPATH cleanup and pruning; no rebuild follows before signing. Use `VERIFY_ONLY=ON`\non the mounted signed DMG; it must never mutate it.\n\nRemove `PlugIns/imageformats/libqpdf.dylib`, `PlugIns/position/libqtposition_nmea.dylib`\n(and their `_debug` variants), `PlugIns/sqldrivers`, `QtSql.framework`, and\n`QtSerialPort.framework`. VNote and its widget tests must not require QtSql; vxcore\nuses native SQLite instead. Keep qtserialport in the CI SDK so macdeployqt can scan\nthe NMEA plugin before pruning. SQL-driver Homebrew libraries/workarounds are not needed.\nKeep QtPdf for Typst, QtPositioning and other positioning plugins, TLS backends,\nQML/Quick/WebEngine, DevTools, universal slices and attribution files.\n\nOnly `en-US.pak`, `zh-CN.pak`, `ja.pak`, and `ko.pak` ship in\n`Contents/Frameworks/QtWebEngineCore.framework/Versions/A/Resources/qtwebengine_locales`.\nMissing/empty retained packs or another locale directory fail packaging. Do not\nprune sibling WebEngine resource paks, ICU data, snapshots or the process helper.\nQt's resource fallback supplies English for unsupported locales without changing\nVNote's application locale or date/number formatting.\n\nThe final-DMG gate rejects load commands to the removed frameworks in **all**\nMach-O files. `scripts/e2e/smoke_webengine_package.py` compiles a disposable native\nprobe against the SDK but runs against the signed package: separate processes for\neach supported locale plus German/British-English fallback, actual DOM rendering,\nand mapped framework/locale paths in both browser and helper. It must not resolve\nruntime resources from the SDK or ship its probe in the app.\nBuild the probe with Qt's imported CMake targets, including their transitive header modules,\nnot a hand-maintained framework/include list. Declare the ARM yield intrinsic before Qt headers\non ARM hosts. Configure and compile share one 120-second budget; target only the host architecture.\nUse `BUILD_WITH_INSTALL_RPATH` with the signed package Frameworks directory and no SDK link-path\nRPATH additions. Keep the 45-second render and 15-second shutdown bounds and all locale checks.\n\n## Windows runtime (Qt 6)\n\nWindows packages use Qt 6 only and publish the `win64` artifact family. Qt uses\nits native Schannel TLS backend; Git sync uses WinHTTP. Do not bundle OpenSSL\nDLLs or a separate UCRT redistributable: supported Windows versions provide UCRT.\nKeep the MSVC runtime deployment and its fallback for newer toolsets, the flat\npackage layout, and the SDK/stray-executable composition gates. WiX stages the\napplication under `Unspecified/`; pruning resolves that component root before\nchecking for `vnote.exe`. ZIP staging remains flat.\n\nDeploy both VNote and VTextEdit without `--no-pdf`: Typst requires `Qt6Pdf.dll`.\nPrune only the unused `imageformats/qpdf.dll` plugin and NMEA position plugin after\ndeployment (including their debug variants); NMEA needs SerialPort, and Qt 6.10.3\nstill deploys it with `--exclude-plugins`. Keep `Qt6Positioning.dll`, the other\npositioning plugins, and WebEngine's QML/Quick/OpenGL dependencies.\nDo not prune the dynamically loaded D3D/DXC compilers or the MSVC runtime set.\nThe extracted-ZIP composition gate rejects PDF image plugins, NMEA, and application-local\nUCRT/API-set DLLs recursively; the Typst gate requires a nonempty `Qt6Pdf.dll`.\nKeep `qtpdf` in the CI SDK for the required runtime and its attribution.\n---\n\n## Update artifacts\n\nOrdinary startup/manual checks remain metadata-only and read-only for the installation;\n`UpdateService` ignores assets. The sole installation entry point is the explicit Windows\n`Update Now` notification action launching the deployed external PowerShell updater, not an\nin-process installer or a manual-dialog action. The script consumes signed **full packages\nonly**, with no unsigned or cross-source fallback. See\n[../docs/update-signing.md](../docs/update-signing.md) and root\n[AGENTS.md § Update Check](../AGENTS.md#update-check).\n\nOn the existing `[Release]` master-push condition, `win64` must publish a ZIP,\n`.manifest.json` and `.manifest.json.minisig`. Retain optional delta publication,\nalthough this updater never consumes deltas. Preserve the stable-channel and\nartifact-list gates. Missing `MINISIGN_SECRET_KEY` must fail release signing, and\nsecret files stay outside the workspace. Missing Gitee mirrors fail closed at\nruntime; never switch the user's source to GitHub.\n\n`prepare_win_updater` must run `package/prepare-win-updater.cmake` with\n`UPDATER_OUTPUT_DIR=<build>/src/win-updater` as a **deploy-only prerequisite**: no network on\nconfigure or ordinary builds. Pin minisign 0.11 archive, executable and upstream ISC license\nby SHA-256; accept only hash-matching cache entries and extract only in the build tree.\nSigning uses this prepared verifier, not a second unpinned download.\n\nInstall `update-vnote.ps1`, `minisign.exe` and `LICENSE.minisign` under `updater/`, all\nnon-optional; retain the existing libsodium notices. Keep the root executable allowlist at\n`vnote.exe` and `QtWebEngineProcess.exe`. Require `vtextedit_typst.dll` separately.\nThe extracted-package gate must\nassert all three helper files and run the **packaged** `updater/minisign.exe -v`,\nprepending that package root to the subprocess PATH for its runtime DLLs. Retain\nall Qt 6 package-composition checks; verifier presence alone is not runtime proof.\n\n## Linux test runtime\n\n`ci-linux.yml` installs test-only runtime dependencies after packaging and runs\nboth the suite and failure diagnostics through `.github/scripts/run-linux-tests.sh`.\nThe wrapper creates a private D-Bus session and unlocked GNOME keyring, verifies\nSecret Service with a write/read/delete probe, then tears down the daemon and\nprivate XDG directories. CTest runs under Xvfb so tests selecting the native xcb\nbackend have a display. Install `xvfb` and `xauth` unconditionally: the final AppImage\nconsumer check also needs them on release builds. It launches packaged `AppRun --help`\nwith `QT_QPA_PLATFORM=xcb` under `xvfb-run -a`; the AppImage ships xcb, not offscreen.\nKeep Qt lookup inside the extracted package and fail on plugin/display/load errors.\nThe child `test_typsteditor` suite must select `QT_QPA_PLATFORM=xcb` explicitly:\nits native layout/raster scenarios must not inherit the parent's offscreen default.\nKeep the credential tests on a real vault; headers from\n`libsecret-1-dev` alone do not provide one.\n\nTLS fixtures use Ubuntu's `/usr/bin/openssl` explicitly, not a PATH executable\nshadowed by the separate packaging OpenSSL install. These environment changes\napply only to tests; release commits skip vault setup and test invocations, not the\nXvfb-backed packaged-consumer check.\n\n## macOS test temporary paths\n\nBoth non-release test launches resolve `TMPDIR` to its physical path before\nstarting Qt or Python fixtures. The system `/var` alias resolves to `/private/var`\nand otherwise trips the intentional sync path-safety checks. Keep those guards\nand deliberate symlinks inside test fixtures unchanged; only the outer temporary\nroot is normalized, without affecting build or packaging paths.\n"},"files":{"tests/AGENTS.md":"# Agent Guidelines for tests\n\nTest infrastructure for VNote using the QtTest framework. Parent VNote Qt tests live under\n`tests/` and are built via CMake's `add_qt_test()` helper; vxcore has a **separate** suite under\n`libs/vxcore/tests/` with its own build dir and `add_vxcore_test()` helper. Tests exercise\nservices, models, controllers, and utilities against the vxcore C backend in isolated test mode.\n\n## Two test locations — pick the right one\n\nVNote has tests in **two separate locations** with different registration helpers and build\nconfigurations. Pick the right one or your test will silently never run.\n\n### Parent VNote tests (`tests/`)\n\nBuilt with the parent project into `build-debug/tests/<category>/` **when `-DVNOTE_BUILD_TESTS=ON`\nis set** (it defaults to OFF; CI turns it ON). Registered via\nthe `add_qt_test()` helper (details in [Adding New Tests](#adding-new-tests) below). Categories:\n`controllers/`, `core/`, `gui/`, `integration/`, `models/`, `utils/`, `widgets/`.\n\n`VNOTE_BUILD_TESTS` also controls the bundled vtextedit test targets. VNote sets\n`VTEXTEDIT_BUILD_TESTS` from that flag; standalone vtextedit keeps its own default ON.\n\nConfigure the test-enabled build dir from repo root (also re-run this if `tests/CMakeLists.txt`\nitself changed): `cmake -B build-debug -DVNOTE_BUILD_TESTS=ON`.\n\n**Build + run:**\n```powershell\ncmake --build build-debug --config Debug --target test_yourthing\nctest --test-dir build-debug -C Debug -R \"^test_yourthing$\" --output-on-failure\n```\n\n### vxcore submodule tests (`libs/vxcore/tests/`)\n\n**Separate build dir required** — the parent `build-debug/` does NOT compile vxcore tests. Use a\ndedicated build dir configured with `-DVXCORE_BUILD_TESTS=ON`. Each test is a standalone\nexecutable (file basename = test target name) registered via `add_vxcore_test()`.\n\n**Add a new test:**\n1. Create `libs/vxcore/tests/test_yourthing.cpp` with its own `main()`. Subtests are functions that return `int` (0 = pass); call them via `RUN_TEST(...)` from `test_utils.h`. Always start `main()` with `vxcore_set_test_mode(1)` so the test uses `%TEMP%\\vxcore_test*` instead of real AppData.\n2. Register in `libs/vxcore/tests/CMakeLists.txt`:\n   ```cmake\n   add_vxcore_test(test_yourthing)\n   ```\n   The helper auto-links `vxcore` and adds `${CMAKE_SOURCE_DIR}/src` + `third_party` include dirs.\n3. Test executables CANNOT call vxcore-internal symbols that lack `VXCORE_API` — they live outside the DLL. If you need an internal helper (e.g., `GetCurrentTimestampMillis()`), re-derive it locally in the test file rather than exporting it.\n\n**Configure (once per machine, or after CMake version upgrade):**\n```powershell\ncmake -S libs/vxcore -B libs/vxcore/build_test -G \"Visual Studio 17 2022\" -A x64 -DVXCORE_BUILD_TESTS=ON\n```\n\n**Build + run:**\n```powershell\ncmake --build libs/vxcore/build_test --config Debug --target test_yourthing\nctest --test-dir libs/vxcore/build_test -C Debug -R \"^test_yourthing$\" --output-on-failure\n```\n\n### Universal rules\n\n- **Anchor the ctest regex** with `^...$` to avoid false matches (e.g., `-R \"^test_sync$\"` won't sweep in `test_session_persistence`).\n- After a CMake version upgrade, stale build dirs fail to reconfigure with errors like `CMakeSystem.cmake.in does not exist`. Delete the offending build dir and re-run the configure command from scratch — do NOT try to repair in place.\n- After modifying a touched module, run the FULL test target for that module (not just your new subtest) to catch regressions; vxcore tests print each subtest name to stdout so you can verify the new one ran.\n\nChild-process readiness is a byte-stream protocol: accumulate stdout until the complete\nmarker arrives under one deadline. IPC slow-start fixtures must use blocking reads rather\nthan pumping the primary event loop while waiting for that marker.\n## Test Structure\n\n```\ntests/\n├── CMakeLists.txt          # Root test config with add_qt_test() helper\n├── helpers/\n│   ├── CMakeLists.txt\n│   ├── test_helper.h       # Common includes\n│   └── temp_dir_fixture.h  # QTemporaryDir wrapper (+ copyFrom() for fixtures)\n├── data/                   # On-disk test fixtures (see \"Test Data Fixtures\" below)\n│   └── vnote3_notebooks/\n│       └── database_notebook/  # Real VNote3 notebook with 17 files + subfolder\n├── core/\n│   ├── CMakeLists.txt\n│   ├── test_error.cpp\n│   ├── test_exception.cpp\n│   ├── test_configservice.cpp\n│   ├── test_notebookservice.cpp\n│   ├── test_bufferservice.cpp   # BufferCoreService tests\n│   ├── test_buffer.cpp          # Buffer2 + BufferService integration tests (29 cases)\n│   └── test_vnote3migrationservice.cpp  # VNote3 migration tests (46 cases)\n└── utils/\n    ├── CMakeLists.txt\n    ├── test_pathutils.cpp\n    └── test_htmlutils.cpp\n```\n\n## CRITICAL: Test Mode for vxcore\n\n**Always enable test mode BEFORE creating vxcore context in tests:**\n\n```cpp\nvoid TestMyService::initTestCase() {\n  // CRITICAL: Must call BEFORE vxcore_context_create()\n  // Prevents tests from corrupting real user data\n  vxcore_set_test_mode(1);\n\n  m_ctx = vxcore_context_create();\n  // ...\n}\n```\n\n**Why this matters:**\n- Without test mode, vxcore uses real `AppData/Local` paths\n- Tests will corrupt actual user configuration\n- Test mode redirects to isolated temp directories\n\nEncryption fixtures created by `prepare_notebook` / `commit_notebook` are self-contained v2\nnotes. Reopen them with `vxcore_encryption_unlock_note`, using the notebook-relative path\ncaptured before closing the buffer; `unlock_notebook` is only for explicit legacy-vault tests.\n\n## Writing Service Tests\n\n```cpp\n// tests/core/test_myservice.cpp\n#include <QtTest>\n\n#include <vxcore/vxcore.h>\n\n#include <core/myservice.h>\n\nnamespace tests {\n\nclass TestMyService : public QObject {\n  Q_OBJECT\n\nprivate slots:\n  void initTestCase();\n  void cleanupTestCase();\n  void testBasicOperation();\n\nprivate:\n  vxcore_context *m_ctx = nullptr;\n};\n\nvoid TestMyService::initTestCase() {\n  vxcore_set_test_mode(1);  // CRITICAL!\n  m_ctx = vxcore_context_create();\n}\n\nvoid TestMyService::cleanupTestCase() {\n  vxcore_context_destroy(m_ctx);\n  m_ctx = nullptr;\n}\n\nvoid TestMyService::testBasicOperation() {\n  vnotex::MyService service(m_ctx);\n  QVERIFY(!service.doSomething().isEmpty());\n}\n\n} // namespace tests\n\nQTEST_GUILESS_MAIN(tests::TestMyService)\n#include \"test_myservice.moc\"\n```\n\n## Enable Tests\nEnable parent and bundled editor tests explicitly at configure time; do not edit CMake files:\n```bash\ncmake -S . -B build -DVNOTE_BUILD_TESTS=ON\n```\n\nCMake retains cached choices. An existing ON build stays ON even though the default is OFF;\nconfigure with `-DVNOTE_BUILD_TESTS=OFF` to remove both sets of test targets from that build.\n\n## Build Tests\n```bash\ncmake --build build --config Release --target test_error test_exception test_pathutils test_htmlutils\n```\n\n### Test Libraries\n\n| Library | Purpose | Links |\n|---------|---------|-------|\n| `core_services` | Service layer (ConfigCoreService, NotebookCoreService, etc.) | Qt6::Core, Qt6::Gui, Qt6::Network, Qt6::Concurrent, vxcore, core_net |\n| `core_net` | Qt-Core/Network-only HTTP helpers (`vnotex::NetworkAccess`) | Qt6::Core, Qt6::Network |\n| `core_configs` | Config classes (ConfigMgr2, MainConfig, SessionConfig, etc.) | core_services, VTextEdit |\n\n`core_services` links **neither `Qt::Widgets` nor `VTextEdit`** — see [`../src/core/services/AGENTS.md`](../src/core/services/AGENTS.md#core_services-is-qt-widgets-free-and-vtextedit-free-contract). Practical consequence for tests: a target that needs QtWidgets or vtextedit headers must say so in its own `LINKS`; nothing arrives transitively through `core_services` any more.\n\n## Run Tests\n\n**Windows (Qt DLLs must be in PATH):**\n```powershell\n$env:PATH = \"C:/Qt/6.9.3/msvc2022_64/bin;\" + $env:PATH\n./build/tests/core/test_error.exe\n./build/tests/core/test_configservice.exe\n./build/tests/core/test_notebookservice.exe\n```\n\n**Using CTest (requires Qt in system PATH):**\n```bash\nctest --test-dir build                    # Run all tests\nctest --test-dir build -R test_error      # Run single test (pattern match)\nctest --test-dir build --output-on-failure  # Show output on failure\n```\n\n**macOS keychain tests:** core tests that exercise real QtKeychain jobs use\n`VNOTE_KEYCHAIN_TEST_MAIN` from `test_helper.h`: a native GUI application on macOS,\n`QCoreApplication` elsewhere. On macOS, run these and keychain-using widget tests with\n`QT_QPA_PLATFORM=cocoa`, not `offscreen`: QtKeychain completes on the native main queue,\nwhich the offscreen UNIX dispatcher does not drain. Do not set\n`QT_EVENT_DISPATCHER_CORE_FOUNDATION`: it also replaces worker-thread dispatchers and\ncan stall queued work. Tests also need an unlocked default keychain; macOS CI creates\na disposable one after compilation, immediately before CTest, so the unlock lease covers\nexecution rather than the cold build. It restores the previous default after testing.\n\nFor asynchronous modal-error tests, wait for controller completion with `QSignalSpy::wait`.\nKeep the dismissal timer active until completion and restrict it to the tested dialog's\nmessage box. Do not stop on the first click or infer dialog readiness from\n`QThread::loopLevel()`.\n\n`ConsentDialogDriver` shares the native resource/preview consent fixture. It owns a\nparent-matched `QPointer<QMessageBox>` and an answer timer through dispatch and destruction\nof the completed message box, not just until the context menu or click returns. A Cocoa native alert's QWidget is a hidden\nmirror (`WA_DontShowOnScreen`), not its interactive surface: click the real NSButton's\nYes/No/YesToAll tag on the active native alert, or the parent's attached sheet for WindowModal.\nNever re-show or click that dummy QWidget during native modal startup. The QWidget fallback\nrequires the matching visible active modal and its visible expected button. Cocoa uses\nan `NSTimer` in common/modal/tracking run-loop modes: a native alert nested inside manual\nQt event dispatch can suppress QTimer activation. Invalidate before answering and on scope\nexit; the same original eight-second deadline covers readiness and completion. Native Escape\nuses the alert's key-equivalent path; Close uses `QMessageBox::close()` to end its native\nmodal helper as well. Neither may be replaced with a No-button click.\n\n**Native WebEngine raster tests:** on macOS, `test_mindmapexport`, `test_htmlpdfexporter`,\n`test_markdownwebjobs` and `test_webviewservice` use `cocoa` and the matching Qt kit's\nplatform plugin, not `offscreen`, so hidden views have native graphics contexts.\nKeep deployed-app WebEngine overrides cleared and non-macOS environments unchanged.\n\nNative Markdown render waits observe the adapter's completed document generation and\nactual DOM, not the independent web-job handshake. JavaScript observations pump events;\nuse one `QTest::qWaitFor` and recheck the generation after observation, rather than a\n`QTRY` predicate that can become false again during its post-success evaluation.\nIf the next assertion requires native currentness, use `waitForCurrentRender`, not the\nDOM-only `waitForRender`; protected/public handoffs retain all token and stale-work checks.\n\nTypst context-menu fixtures select Paste by Qt's `edit-paste` action ID and retire\nresources after action admission, not from a timer that can fire during Cocoa's menu\nflash. Empty clipboard MIME data is nullable. Native drag fixtures must release input\nwithout depending on GUI timers: Windows OLE and Cocoa tracking loops can starve them.\nUse joined native-input workers, restoring the cursor and preventing late input from\nreaching the next case; Cocoa retains app-local NSEvents rather than global HID input.\nWindows drag motion uses non-coalesced `SendInput` records in normalized virtual-desktop\ncoordinates. After selecting text, acquire foreground, repaint, deliver posted `LayoutRequest`\nevents, repaint the resolved gutter layout and complete `DwmFlush` before measuring positions.\nAfter source-pointer receipt, recheck mapped start/end and `widgetAt` before pressing: a\nfirst-paint gutter update must not move the viewport underneath cached coordinates.\nFailure diagnostics record native move/press/activation messages and GUI-thread state.\nQuery `GetGUIThreadInfo(guiThreadId)` from the worker, not its own GetFocus/GetCapture;\nnever consume or reroute an event just to make the fixture pass.\nWindows workers acknowledge the press and threshold, then post a registered native message to\nthe source HWND. The GUI filter acknowledges it only while an owned `QDrag` exists. Send one\ndistinct bootstrap motion, await capture transfer from the fixture to a GUI-thread window,\nthen send the final destination; an already-posted capture never skips the bootstrap motion.\nAll waits share the original deadline. Qt timers or waiting for DragEnter before startup motion\ncan deadlock in Qt's filtered native pump. Destination arrival can be DragEnter or DragMove.\nRelease follows arrival or the bounded deadline; joined workers always attempt release and\nrestore the saved pointer. Cocoa\ncreates timestamped NSEvents at dispatch, without warping ahead of delivered source events.\nCreate native peers before measuring drag coordinates. On Cocoa, find the source viewport's\nnearest already-native QWidget ancestor without creating a peer. Its flipped QNSView coordinates\nmatch Qt's window coordinates: map locally to that ancestor, then use `convertPoint:toView:nil`\nfor the event and `convertRectToScreen:` plus the primary-screen Y flip for the Quartz pointer.\nDo not mix Qt's cached global frame origin into native input or compensate with titlebar offsets.\nTypst shortcut/focus tests use xcb under Xvfb on Linux, not offscreen. Keep the normal\nQtTest crash handler. Build the host Typst module's `test_typstviewwindow_insertion` executable\nonce, then select `^test_typstviewwindow_(insertion|cleanup|resources)$` in CTest. These serial\ngroups share the executable and each retain a 240-second deadline. With `VNOTE_TYPST_TEST_GROUP`\nunset, a direct executable launch runs all default-enabled cases. Each group writes plaintext stdout and\nits own `test_typstviewwindow_<group>.xml` JUnit file in the CTest working directory. CI\npreserves original parent/native CTest logs and these reports before diagnostic reruns,\nsince hosted stdout can omit assertions.\n\nThe controller executable is also partitioned without extending deadlines. Build\n`test_typstviewwindowcontroller` once and run\n`^test_typstviewwindowcontroller(_images|_resources)?$`: the base registration covers\nnavigation, followed by image validation/storage and resource acquisition/viewing.\nAll three are serial, retain 60-second deadlines and `-nocrashhandler`, and use the\nsame native runtime environment. Empty/unset `VNOTE_TYPST_CONTROLLER_TEST_GROUP` runs\nall cases directly; unknown groups fail. Slot boundaries `imageStorage()` and\n`resourceSourceRejection()` keep every test in exactly one group.\n\nThe `resourceSnapshotBytes(retained-gif-mime-not-replaced-file)` row is disabled by default.\nSet `VNOTE_TEST_RETAINED_GIF_SNAPSHOT=1` to register it; only the literal `1` enables it.\nThe PNG snapshot row and every other resource test remain enabled. Windows Clipboard\nUser Service can interfere with the GIF row on a shared desktop; the opt-in does not\nchange its assertions or convert failures into skips.\n\nAll three parent CI workflows set this flag, including for diagnostic reruns. **Pre-push\nverification must also set it when this suite is affected**; a default local pass is not\nproof of the full CI/pre-push coverage. No build reconfiguration is needed:\n\n```powershell\n$env:VNOTE_TEST_RETAINED_GIF_SNAPSHOT = \"1\"\nctest --test-dir build-debug -C Debug -R '^test_typstviewwindow_resources$' --output-on-failure\n```\n\n```bash\nVNOTE_TEST_RETAINED_GIF_SNAPSHOT=1 ctest --test-dir build-debug -R '^test_typstviewwindow_resources$' --output-on-failure\n```\n\nSet Qt's active window explicitly at focus-sensitive fixture operations; `activateWindow()`\nalone depends on a window manager under Xvfb. After dismissing a modal dialog, wait for\nwindow activation and editor focus before sending shortcuts. Settle native exposure and\nactivation before measuring drag coordinates. Before inserting tab-replacement or presentation\ndrafts programmatically, wait for editor focus: that registers the active writer used by Save.\nThe lazy-view `showWindow()` helper establishes those exposure/activation/focus preconditions;\ndo not require editor focus for hidden/disabled editors or encrypted buffers already locking.\nNative popup fixtures must settle the owner's activation before promotion/opening. A\ntransient parent shares `QWindow::isActive()`; when exact ownership matters, wait for both\n`QGuiApplication::focusWindow()` and `QApplication::activeWindow()` to match the intended host.\n`activateNativeHost()` observes the target's closest existing native surface, not only the\nroot window: fullscreen native children can cover the root's entire exposed region. Retain\ncurrent host/surface ancestry and exact native activation checks, without creating peers or\nforcing the child focus that a test is meant to observe. After modal cancellation, restore\nthe fixture's native top-level activation if needed, then observe automatic child-focus\nrestoration; never call `setFocus()` to supply the child focus under test. Native dialog\ninput similarly waits for `QGuiApplication::focusObject()`, not just QWidget `hasFocus()`.\nContext-menu fixtures wait for both selection and deselection to reach WebEngine before clicking.\nUse an explicit blank non-text region for an unselected context: Cocoa/Chromium can select\nthe word under a right-click even after the fixture cleared selection. Keep selected-link,\nimage and empty-context reset assertions distinct.\n\n`test_viewarea_tab_reuse` retains only its unmodified, version-stamped bundled asset directories\nbetween QtTest rows. It removes all local state and all non-bundle app entries before creating\na fresh context, config, notebook, services and WebEngine profile. Obtain cleanup paths from\nConfigMgr2, and prune only in the test class `init()`, never inside `Fixture::initialize()`:\nintra-row fixture recreation intentionally tests persisted sessions. This suite must not edit\ninstalled assets, including `web/css/user.css` or user-created templates; asset-customization\nfixtures must retain the full test-directory reset. Keep the existing suite deadlines.\n\nPDF.js text-item boundaries are not word boundaries: ligatures can split a word across\nitems. Reconstruct text by concatenating `item.str` and adding a newline only for\n`item.hasEOL`, as the bundled viewer does; do not insert spaces between every item.\n\n### Local pre-push flaky-test whitelist\n\nOn a **shared interactive Windows desktop only**, the following native-input failures\nmay be treated as non-gating for a local push. This is a closed list of failure sites,\nnot permission to skip an executable or any product-behavior assertion.\n\n| Executable | Exact QtTest case / row | Eligible failure site |\n|---|---|---|\n| `test_viewarea_tab_reuse` | `previewReaderNavigationAndNativeHost` | First source-reader `B` link's `clickNativeSurface` hit-test precondition |\n| `test_viewarea_tab_reuse` | `previewOpenInNewTabs(main-source)` | Open Link button's `clickNativeSurface` hit-test precondition |\n| `test_viewarea_tab_reuse` | `previewOpenInNewTabsDuplicateConsent(other-tab-save)` | Incoming editor's `hasFocus()` precondition before consent/promotion |\n\n**All** of these conditions are required for each use:\n\n1. Build the affected targets and attempt the **full executable** at the revision being\n   pushed. Preserve the first failure report. Windows GUI executables may omit QtTest\n   assertions from CTest's captured stdout; use explicit QtTest `-o <path>,txt` and\n   `-o <path>,junitxml` outputs for diagnosis with the registered runtime environment.\n2. Match both the exact row and failure site above. For a click failure, diagnostics must\n   show a visible, in-bounds target, an inactive native window, and null/foreign\n   `QApplication::widgetAt()` at the click position. For the editor-focus failure, also\n   establish that the native window was inactive. A bare `clickNativeSurface`/`hasFocus`\n   failure or a passing retry alone is **not** enough to classify the failure.\n3. The affected case must pass in isolation at the same revision, and all remaining rows\n   and other affected suites must run and pass. Prefer an exclusive desktop run when\n   available. Do not retry unchanged runs merely until one passes; reruns must investigate\n   a cause or follow an environment correction.\n4. The unpushed changes must not modify the affected native preview input, focus, geometry,\n   promotion, hit-test helpers, fixture activation or relevant platform configuration.\n   Those changes require a clean native verification run or a separate explicit user exception.\n5. Report the exact case, native-state evidence and local waived gate as a **skip**, not\n   a pass. Any other failure, including later content, routing, save/discard/cancel,\n   persistence assertions, crashes or timeouts, remains blocking even in a listed case.\n\nCI and packaging gates are never waived. Do not add `QSKIP`, exclude the executable or\nrows from CTest, weaken assertions, or change deadlines to implement this policy.\nReassess/remove an entry when its native activation cause is fixed.\n\nThe 2026-10-29 investigation observed a visible/in-bounds preview button with an inactive\nnative window and null `widgetAt`, while failing rows varied across runs; the unmodified\nfull suite subsequently passed all 73 cases. That earlier fixture requested activation\nwithout waiting for native activation; the current fixture observes its owned native surface.\nThe original reader-click rejection and editor-focus\nfailure did not identify a covering process or independently prove their native-window\nstate, so those assertion messages alone do not qualify for the exception.\n\n### VTextEdit.dll runtime copy\n\nTests that link `VTextEdit` (directly, or transitively via `core_configs`) load `VTextEdit.dll` at runtime. **`core_services` alone no longer pulls it in**, so a pure-core test that links only `core_services` + `vxcore` runs with no `VTextEdit.dll` anywhere near it. The build copies the DLL next to each subdirectory's test exes via a `POST_BUILD` step anchored on one test target per subdir (the canonical reference is `tests/utils/CMakeLists.txt:78-83`):\n\n```cmake\n# Copy VTextEdit DLL next to test executable so CTest can find it at runtime.\nadd_custom_command(TARGET test_clipboard_image POST_BUILD\n  COMMAND ${CMAKE_COMMAND} -E copy_if_different\n    $<TARGET_FILE:VTextEdit>\n    $<TARGET_FILE_DIR:test_clipboard_image>\n)\n```\n\nWith Typst enabled, `VTextEdit.dll` directly depends on `vtextedit_typst.dll`. The common\n`add_qt_test()` supplies Windows CTest lookup paths for Qt, VTextEdit and the native backend\nwithout linking pure-core tests to them. Tests replacing ENVIRONMENT must restore PATH\nafterward; append it separately to each test instead of copying an escaped PATH list.\n\n**When adding tests to a NEW subdirectory under `tests/` whose targets link `VTextEdit`**, that subdir's `CMakeLists.txt` MUST include this `POST_BUILD` block anchored on any one such test target. Without it, `ctest` reports the test as failed with exit code `0xc0000135` (Windows \"DLL not found\") and the test binary never reaches its `main()`. One copy per subdir is enough; `copy_if_different` makes incremental rebuilds free.\n\n### Running execs that depend on VTextEdit.dll (CRITICAL — avoid false-positive smoke tests)\n\n`build-debug/src/vnote.exe` and widget/editor tests load `VTextEdit.dll` and its native\n`vtextedit_typst.dll` dependency, plus the matching Qt 6 runtime. The editor/runtime DLLs\nare staged in `build-debug/bin` and beside VNote; Typst test targets also refresh their\nlocal copies. Pure-core tests linked only to `core_services` + `vxcore` remain independent\nof VTextEdit. Direct test launches and disposable app copies still need the configured\nQt/runtime lookup paths. A missing DLL can show a loader modal before `WinMain`.\n\n**This is a verification trap**: when the loader dialog blocks the process, the OS reports the process as alive (it has a PID, is technically running), so naive checks like `Start-Process … -PassThru` + `Sleep` + `HasExited` return `$false` → you falsely conclude the binary started. The process is actually frozen in pre-WinMain limbo waiting for someone to click the dialog. `Stop-Process -Force` afterwards silently dismisses the dialog and the lie is preserved.\n\n### Agent GUI smoke testing (Windows)\n\nUse `--no-single-instance` to test a separate VNote process without closing the user's running\ninstance. **It skips locking and IPC, not data isolation.** Never launch the agent's GUI smoke test\nagainst the normal profile or real notebooks. Create a fresh portable copy for each run; its\nexisting `config/` folder redirects config, session and WebEngine data to that copy.\n\nBefore using this recipe, check the OS Documents folder's `my_notebook` path. If it is missing or\nan empty directory, first-run setup can create/open a bundled notebook there, **outside the portable\ncopy**; use a disposable OS account instead. Do not edit, empty, or delete the user's directory to\nsatisfy the collision guard. Continue testing only the scratch file in the disposable copy.\n\nRun from the repository root after building `vnote`. Set `$build` to the build you just verified;\nthe example derives the matching Qt runtime from its CMake cache, rather than mixing Qt versions.\nRun both blocks in the same persistent PowerShell terminal, keeping it open during GUI inspection\nand cleanup; avoid a one-shot wrapper that captures inherited process pipes.\n\n```powershell\n$ErrorActionPreference = \"Stop\"\n$build = \"build-debug\" # Replace with the existing build you just compiled\n$qtDir = (Select-String -Path \"$build/CMakeCache.txt\" -Pattern '^Qt6_DIR:PATH=' |\n          Where-Object { $_.Line -notmatch 'NOTFOUND$' } | Select-Object -First 1).Line\nif (-not $qtDir) { throw \"No configured Qt directory in $build/CMakeCache.txt\" }\n$qtBin = [IO.Path]::GetFullPath((Join-Path ($qtDir -split '=', 2)[1] \"../../../bin\"))\n$env:PATH = \"$qtBin;\" + (Resolve-Path \"$build/bin\").Path + \";\" + $env:PATH\n$env:QT_QPA_PLATFORM_PLUGIN_PATH = Join-Path $qtBin \"../plugins/platforms\"\n$env:QTWEBENGINEPROCESS_PATH = Join-Path $qtBin \"QtWebEngineProcess.exe\"\n\n$smoke = Join-Path $build (\"agent-smoke-\" + [guid]::NewGuid().ToString(\"N\"))\nNew-Item -ItemType Directory -Path \"$smoke/config\" | Out-Null\nCopy-Item \"$build/src/vnote.exe\", \"$build/src/vnote_extra.rcc\", \"$build/src/qt.conf\" $smoke\nif (Test-Path \"$build/src/translations\") {\n  Copy-Item \"$build/src/translations\" \"$smoke/translations\" -Recurse\n}\nSet-Content -Path \"$smoke/smoke.txt\" -Value \"Disposable agent smoke-test note\" -Encoding UTF8\n$proc = Start-Process -FilePath \"$smoke/vnote.exe\" -PassThru `\n  -ArgumentList @(\"--no-single-instance\", \"--log-stderr\", \"`\"$smoke/smoke.txt`\"\") `\n  -RedirectStandardError \"$smoke/stderr.log\" `\n  -RedirectStandardOutput \"$smoke/stdout.log\"\nStart-Sleep -Seconds 5\n$proc.Refresh()\nif ($proc.HasExited) { throw \"VNote exited with $($proc.ExitCode); inspect $smoke/stderr.log\" }\nforeach ($module in @(\"VTextEdit.dll\", \"vtextedit_typst.dll\")) {\n  if (-not ($proc.Modules | Where-Object { $_.ModuleName -ieq $module })) {\n    throw \"$module not loaded: check runtime DLLs, not just process liveness\"\n  }\n}\nWrite-Output \"Inspect PID $($proc.Id): its own window must display smoke.txt and the note text\"\n```\n\nThe runtime check is only a prerequisite: inspect the **actual window and requested file**, and\nexercise the feature under test. The main window title can remain `VNote`; do not require the file\nname in that title. Startup/file-open diagnostics are in `$smoke/stderr.log`. Add `--detached-view`\nwhen testing detached windows. Use only scratch notes/notebooks and no production sync credentials.\n\nQuit through VNote's UI (answer **No** if asked to minimize to the system tray), allow save/sync\nworkers to drain, then clean up only this run's copy:\n\n```powershell\n$proc.WaitForExit()\nRemove-Item -LiteralPath $smoke -Recurse -Force\n```\n\nDo not reuse the generated profile for the next run: loading its saved session also performs\nnormal Windows launch-on-login reconciliation. Do not enable launch-on-login in a disposable copy.\nIn-app restart drops the flag and uses the normal guard; start another fresh copy explicitly instead.\nChanging `TEMP`/`TMP` does not isolate the fixed IPC name. For guard/forwarding tests use the unique\nserver/lock overrides in `test_singleinstanceguard`, never the user's production pipe or lock.\nBehavior contract: [Agent / manual testing bypass](../src/core/AGENTS.md#agent--manual-testing-bypass).\n\nThe same PATH prepend is required before running parent `test_*.exe` directly. `ctest --test-dir build-debug` inherits the caller's PATH, so set it once at the start of the session.\n\nvxcore submodule tests (`libs/vxcore/build_test/bin/Debug/test_*.exe`) do NOT depend on Qt or VTextEdit and need no PATH setup.\n\n## Adding New Tests\nUse the `add_qt_test()` helper function in CMakeLists.txt:\n\n```cmake\n# tests/module/CMakeLists.txt\nadd_qt_test(test_myclass\n  SOURCES\n    test_myclass.cpp\n    ${CMAKE_SOURCE_DIR}/src/module/myclass.cpp\n  LINKS\n    Qt6::Gui  # Optional: extra Qt modules\n  GUILESS     # Optional: do NOT link Qt6::Widgets\n)\n```\n\n### The `GUILESS` flag is load-bearing (linking only)\n\n`GUILESS` controls **one thing**: whether `add_qt_test` adds `Qt6::Gui` + `Qt6::Widgets` to the target. Non-GUILESS targets get them; GUILESS targets do not. This became load-bearing when `core_services` stopped linking `VTextEdit` (which used to leak `Qt::Widgets` in transitively) — see [`../src/core/services/AGENTS.md`](../src/core/services/AGENTS.md#core_services-is-qt-widgets-free-and-vtextedit-free-contract).\n\nIt does **not** — and cannot — choose between `QTEST_MAIN` and `QTEST_GUILESS_MAIN`. That is a source-level macro decision made at the bottom of the test `.cpp`. The two are related but independent:\n\n- A test using `QTEST_GUILESS_MAIN` that merely **includes** a QtWidgets header (without ever constructing a `QApplication`) is correctly labelled `GUILESS` and should list `Qt6::Widgets` explicitly in `LINKS`. `test_clipboard_image`, `test_exportcontroller`, `test_findunitedentry`, `test_missing_nodes_qt`, `test_duplicate_open_guard`, `test_newnotebookcontroller`, and `test_openvnote3notebookcontroller` all do exactly this.\n- Never \"fix\" a missing-QtWidgets compile error by relaxing `core_services`. Add the module the failing target actually needs to that target's `LINKS`.\n\n## Qt Test Macros\n\n| Macro | Purpose |\n|-------|---------|\n| `QTEST_MAIN(Class)` | Creates main(), runs tests with QApplication |\n| `QTEST_GUILESS_MAIN(Class)` | Headless test runner (preferred) |\n| `QVERIFY(condition)` | Assert condition is true |\n| `QCOMPARE(actual, expected)` | Assert equality |\n| `QFETCH(type, name)` | Fetch data-driven test value |\n| `QTest::addColumn<T>(\"name\")` | Declare data column |\n| `QTest::newRow(\"name\")` | Add data row |\n| `QSKIP(\"reason\")` | Skip test |\n| `QTest::ignoreMessage(type, msg)` | Suppress expected qDebug/qWarning/qCritical |\n\n## Test Data Fixtures\n\nTests that need realistic directory structures (e.g., legacy notebook migration) use on-disk fixtures under `tests/data/` instead of hardcoding JSON and file creation in C++.\n\n### Pattern\n\n1. **Create fixture directory** under `tests/data/` with real files on disk\n2. **Locate at runtime** using `QFINDTESTDATA` (works out of the box with Qt6+CMake)\n3. **Copy to temp dir** using `TempDirFixture::copyFrom()` for test isolation\n\n### Example\n\n```cpp\n// In your test class\nQString findFixture(const QString &p_relPath) {\n  // Path is relative to the test source file\n  QString path = QFINDTESTDATA(p_relPath);\n  QVERIFY2_RETURN(!path.isEmpty(),\n    qPrintable(QStringLiteral(\"Fixture not found: %1\").arg(p_relPath)),\n    QString());\n  return path;\n}\n\nvoid TestMyService::testWithFixture() {\n  // Locate fixture (relative to this .cpp file's directory)\n  QString fixturePath = findFixture(\n    QStringLiteral(\"../data/vnote3_notebooks/database_notebook\"));\n  QVERIFY2(!fixturePath.isEmpty(), \"Fixture not found\");\n\n  // Copy to isolated temp dir\n  TempDirFixture workDir;\n  QString sourceDir = workDir.copyFrom(fixturePath, QStringLiteral(\"source\"));\n  QVERIFY2(!sourceDir.isEmpty(), \"Failed to copy fixture\");\n\n  // Test against the copy\n  // ...\n}\n```\n\n### Adding New Fixtures\n\n1. Create directory under `tests/data/` with descriptive name\n2. Add all files the test needs (stubs are fine — content doesn't have to be real)\n3. For Chinese/Unicode filenames, the files must exist on disk with those names\n4. No CMake changes needed — `QFINDTESTDATA` resolves paths automatically via `QT_TESTCASE_SOURCEDIR`\n\n### Existing Fixtures\n\n| Fixture | Path | Description |\n|---------|------|-------------|\n| database_notebook | `tests/data/vnote3_notebooks/database_notebook/` | VNote3 notebook with 17 files (Chinese + ASCII), 1 subfolder, 3 attachment dirs |\n\n## Real Keychain Usage\n\nAll **new or modified tests** that instantiate the real `SyncCredentialsStore` (not a `MockCredentialsStore` / in-memory fake) MUST register a `tests::KeychainGuard` to track and clean up written PAT entries.\n\n### Why\n\nWithout deterministic cleanup, tests leak PAT entries into the host OS keychain. Fixed-ID tests overwrite themselves harmlessly, but UUID-based writes accumulate forever. QtKeychain has no enumerate API (see `src/core/services/synccredentialsstore.cpp:71`), so cleanup cannot be done by sweeping the keychain at startup; it must be driven by tracking each write.\n\n### How\n\n```cpp\n#include \"../helpers/keychain_guard.h\"\n\n// Class-level member:\ntests::KeychainGuard *m_keychainGuard = nullptr;\n\n// In initTestCase(), after registering SyncCredentialsStore:\nauto *credStore = m_services.get<vnotex::SyncCredentialsStore>();\nm_keychainGuard = new tests::KeychainGuard(credStore, this);\n\n// In cleanupTestCase(), BEFORE destroying the vxcore context:\nif (m_keychainGuard) {\n  m_keychainGuard->cleanup();\n  delete m_keychainGuard;\n  m_keychainGuard = nullptr;\n}\n\n// For indirect writes (e.g. via SyncService::enableSyncForNotebook),\n// also call track() defensively in case the credentialsStored signal races:\nm_keychainGuard->track(notebookId);\n```\n\n### Ordering\n\n`cleanup()` MUST run before the vxcore context is destroyed. The guard talks to `SyncCredentialsStore`, which depends on `ServiceLocator` and the live vxcore context; tearing the context down first leaves the guard with dangling references.\n\n### Cross-reference\n\nFor the prod-side cleanup contract (5 sites in `SyncService`), see [`../src/core/services/AGENTS.md` § Credential Cleanup Invariants](../src/core/services/AGENTS.md#credential-cleanup-invariants).\n\n### Mandatory `KeychainGuard` rollout (post-fix-failing-tests plan)\n\nAll sync tests that instantiate `SyncCredentialsStore` (whether through `ServiceLocator` or by local construction) MUST register a `tests::KeychainGuard`. This is non-negotiable: missing guards cause cumulative `notebook_sync_pat_<uuid>` accumulation in the Windows Credential Manager, eventually tripping Win32 error 8 (\"Not enough storage\") which cascades every sync test into failure on subsequent runs.\n\nAs of 2026-06-05, the following tests are compliant:\n\n- `test_synccredentialsstore` (canonical reference)\n- `test_sync_signal_auto_baseline`, `test_sync_signal_baseline`\n- `test_sync_hooks`, `test_sync_ops`, `test_sync_auto_route`, `test_sync_close_block`\n- `test_bootstrap_and_persist`\n- `test_syncservice`, `test_syncservice_lifecycle`\n\n(10 tests total.) New sync tests MUST follow the template in the \"Real Keychain Usage → How\" section above. Tests that do NOT touch the keychain (`test_eventbridge_sync`, `test_notebookcoreservice_sync`) are exempt.\n\n**Operator note (Windows):** the keychain entry surfaces in `cmdkey /list` as `LegacyGeneric:target=notebook_sync_pat_<uuid>` (CRED_TYPE_GENERIC). `KeychainGuard::cleanup()` delegates to `SyncCredentialsStore::deleteCredentials()`, which handles the full target name internally; operators do NOT need to manage the `LegacyGeneric:target=` prefix manually when invoking the guard.\n\n## Current Test Coverage\n\n| Test | Class | Test Cases |\n|------|-------|------------|\n| test_error | `Error`, `ErrorCode` | 23 |\n| test_exception | `Exception` | 20 |\n| test_pathutils | `PathUtils` | 68 |\n| test_htmlutils | `HtmlUtils` | 31 |\n| test_fileutils2 | `FileUtils2` | 15 |\n| test_configservice | `ConfigCoreService` | 10 |\n| test_notebookservice | `NotebookCoreService` | 33 |\n| test_bufferservice | `BufferCoreService` | - |\n| test_buffer | `Buffer2` + `BufferService` | 29 |\n| test_vnote3migrationservice | `VNote3MigrationService` | 46 |\n| test_searchservice | `SearchCoreService` | - |\n| test_servicelocator | `ServiceLocator` | - |\n| test_configmgr2 | `ConfigMgr2` | 7 |\n| test_hookmanager | `HookManager` | 24 |\n| test_hookintegration | Hook integration | 10 |\n| **Total** | | **267+** |\n\n## Debugging Tips\n\n### First-run Windows theme reports\n\nThe Windows CTest registration for `test_themeservice` writes\n`tests/gui/test_themeservice.txt` and `tests/gui/test_themeservice.xml` in the build tree,\nwhile retaining console output. CI includes both in `native-typst-test-report` before\nany diagnostic rerun can overwrite the local files. Inspect these original reports first;\na passing rerun does not establish that the first-run failure was repaired. File reports\ncapture QtTest diagnostics, not failures occurring before QtTest initializes.\n\n### Windows exit code `0xC0000409` is NOT a stack overrun\n\n`0xC0000409` (`STATUS_STACK_BUFFER_OVERRUN`) is misleading. On Windows, Qt's `qFatal()` calls `__fastfail(FAST_FAIL_FATAL_APP_EXIT)`, which the OS reports as `0xC0000409`. The actual cause is almost always a `qFatal()` deep inside Qt, e.g., `QFontDatabase: Must construct a QGuiApplication before accessing QFontDatabase` raised when a `QTEST_GUILESS_MAIN` test transitively touches font-database code.\n\nWhen a test exits with this code, **re-run it with file-based output capture** to surface the real Qt diagnostic:\n\n```powershell\n$env:PATH = \"C:/Qt/6.9.3/msvc2022_64/bin;\" + $env:PATH\n& \"build/.../tests/<category>/test_name.exe\" -o \"output.txt,txt\"\nGet-Content output.txt | Select-String \"QFATAL|FAIL\"\n```\n\nThe `QFATAL : ...` message line names the real cause. Do NOT chase the test for recursion, oversized stack allocations, or fixture loops; that is a false trail. Fix the underlying Qt precondition (switch to `QTEST_MAIN`, guard the GUI-dependent code path, or short-circuit before the offending Qt subsystem is touched).\n\n## Related Modules\n\n- [`../src/core/AGENTS.md`](../src/core/AGENTS.md) — Services and hooks being tested\n- [`../AGENTS.md`](../AGENTS.md) — Code style, architecture overview\n","src/AGENTS.md":"# src/ — Architecture & Source-Wide Patterns\n\nThis file is inherited by every module under `src/`. It holds the full VNote architecture\n(MVC layering, design-decision rationale, directory tree) and the Qt/C++ patterns that apply\nto all source code regardless of module.\n\nThe normative MVC rules table and the repo-wide build/style rules live in the root\n[AGENTS.md](../AGENTS.md).\n\nPackaging and CI rules — including `src/Packaging.cmake` and Qt 6 deployment — live in\n[.github/AGENTS.md](../.github/AGENTS.md). **An edit to `src/Packaging.cmake` does not\nauto-load that file; open it explicitly.**\n\n---\n\n## Architecture\n\nVNote uses a **clean architecture** with **Model-View-Controller (MVC)** pattern and dependency injection for testability and future plugin support.\n\n### Core Principles\n\n1. **MVC Separation** — Models hold data, Views display it, Controllers handle logic\n2. **Dependency Injection** — No singletons; dependencies passed via ServiceLocator\n3. **Service Layer** — Business logic encapsulated in services, accessed via ServiceLocator\n4. **Hook System** — WordPress-style extensibility for plugins\n\n### MVC Architecture (CRITICAL)\n\nVNote strictly follows the MVC pattern. **All new code MUST adhere to this structure.**\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│                     Controllers                             │\n│  (src/controllers/ - Handle user actions, business logic)   │\n│                                                             │\n│  NotebookNodeController, NewNoteController, etc.            │\n└─────────────────────────────────────────────────────────────┘\n        │                                       │\n        │ Manipulates                          │ Emits signals to\n        ▼                                       ▼\n┌───────────────────────┐       ┌──────────────────────────────┐\n│        Models         │       │           Views              │\n│  (src/models/)        │◄──────│  (src/views/)                │\n│                       │       │                              │\n│  NotebookNodeModel    │ Data  │  NotebookNodeView            │\n│  (QAbstractItemModel) │ flows │  (QTreeView subclass)        │\n└───────────────────────┘       └──────────────────────────────┘\n        │                                       │\n        │ Fetches data from                    │ Receives from\n        ▼                                       ▼\n┌─────────────────────────────────────────────────────────────┐\n│                     ServiceLocator                          │\n│  (DI container - NOT a singleton, passed by reference)      │\n│                                                             │\n│  ┌─────────────────┐  ┌─────────────────────┐  ┌───────────────────┐  │\n│  │ConfigCoreService│  │ NotebookCoreService │  │SearchCoreService  │  │\n│  └─────────────────┘  └─────────────────────┘  └───────────────────┘  │\n└─────────────────────────────────────────────────────────────┘\n                              │\n                              ▼\n┌─────────────────────────────────────────────────────────────┐\n│                         vxcore                              │\n│  (C library: notebook/config/search backend in libs/vxcore) │\n└─────────────────────────────────────────────────────────────┘\n```\n\n### MVC Responsibilities\n\n| Layer | Location | Responsibility | Example |\n|-------|----------|----------------|---------|\n| **Model** | `src/models/` | Data representation, Qt Model/View integration | `NotebookNodeModel` exposes node hierarchy via `QAbstractItemModel` |\n| **View** | `src/views/` | Display data, capture user input, emit signals | `NotebookNodeView` renders tree, emits `nodeActivated` signal |\n| **Controller** | `src/controllers/` | Handle actions, orchestrate Model/View, business logic | `NotebookNodeController` handles new/delete/rename operations |\n| **Service** | `src/core/services/` | Domain operations, data access via vxcore | `NotebookCoreService` wraps vxcore C API for notebook CRUD |\n\n### MVC Example: Notebook Node Operations\n\n```cpp\n// Controller handles user action\nvoid NotebookNodeController::newNote(const NodeIdentifier &p_parentId) {\n  // 1. Emit signal to View to show dialog\n  emit newNoteRequested(p_parentId);\n}\n\n// View shows dialog, then calls back to Controller\nvoid NotebookExplorer2::onNewNoteResult(const NodeIdentifier &p_parentId,\n                                        const NodeIdentifier &p_newNodeId) {\n  m_controller->handleNewNoteResult(p_parentId, p_newNodeId);\n}\n\n// Controller updates Model\nvoid NotebookNodeController::handleNewNoteResult(const NodeIdentifier &p_parentId,\n                                                  const NodeIdentifier &p_newNodeId) {\n  // Model reloads from NotebookCoreService\n  m_model->reloadNode(p_parentId);\n}\n```\n\n### MVC Rules (MUST FOLLOW)\n\nThe normative rules table lives at\n[root AGENTS.md § MVC Rules (MUST FOLLOW)](../AGENTS.md#mvc-rules-must-follow) — that is the\nsingle source of truth, and the anchor `controllers/`, `models/`, `views/` and `widgets/` all\nlink to. Do not copy the table here.\n\n### Key Design Decisions\n\n| Decision | Rationale |\n|----------|-----------|\n| ServiceLocator is NOT a singleton | Enables testing with mock services; explicit dependencies |\n| Services wrap vxcore C API | Qt-friendly interface; encapsulates C interop |\n| Controllers are QObject, not QWidget | Testable business logic without GUI dependencies |\n| Widgets receive `ServiceLocator&` | Constructor injection; no global state |\n| Some files carry a `2` suffix (`MainWindow2`, `Buffer2`, …) | Historical artifact of the now-complete migration off the legacy singleton architecture. The pre-migration counterparts have been removed; the suffix is retained on those existing classes only to avoid a churny rename. The migration is finished, so **new code uses the plain, unsuffixed name unless it genuinely conflicts with an existing type** (e.g. a brand-new `EncodingButton` gets no suffix). Never introduce a `3` suffix. |\n| `Buffer2` is a lightweight copyable handle (like `QModelIndex`) | Returned by `BufferService::openBuffer()`, delegates to `BufferCoreService`; NOT a `QObject`, not heap-allocated |\n| `BufferService` privately inherits `BufferCoreService` | Hook-aware wrapper that fires `vnote.file.*` hooks around core operations |\n| `NodeIdentifier` is a standalone value type | Identifies a node by `notebookId` + `relativePath`; used by `Buffer2`, controllers, and views |\n| `ConfigMgr2` is the ONLY way to access typed config | Owns `MainConfig`/`SessionConfig` with properly merged defaults. NEVER construct a throwaway `MainConfig` from raw JSON — use `m_services.get<ConfigMgr2>()` instead. See `src/core/AGENTS.md` for details. |\n| `ConfigMgr2::getFileFromConfigFolder()` for path resolution | Resolves relative config paths (e.g., `\"web/markdown-viewer-template.html\"`) against the app data directory. Do NOT use `ConfigCoreService::getDataPath()` + manual `QDir::filePath()`. |\n| `PathExists()`/`IsDirectory()`/`IsRegularFile()` wrappers | NEVER pass raw `std::string` to `std::filesystem` — use these wrappers or `PathFromUtf8()` for non-ASCII path safety on Windows |\n\n### Key Design Decisions (ViewArea2 Framework)\n\n| Decision | Rationale |\n|----------|-----------|\n| `ViewAreaController` is the orchestrator | Handles open/close/split/move logic, fires hooks, uses WorkspaceCoreService |\n| `ViewArea2` is a pure view | Owns QSplitter tree + ViewSplit2 instances, no business logic |\n| `ViewSplit2` maps 1:1 to vxcore workspace | Each tab widget pane corresponds to one `WorkspaceCoreService` workspace |\n| `ViewWindow2` receives an initial `Buffer2` in its constructor | Opt-in same-type windows can rebind through the guarded replacement lifecycle while retaining their ID/resources; queued work must check buffer generation. See [tab reuse](widgets/AGENTS.md#note-tab-reuse). |\n| `ViewWindowFactory` maps file types to creators | Registry pattern; plugins register creators for new file types |\n| Splitter orientation follows Vim convention | Left/Right split → `Qt::Horizontal`, Up/Down split → `Qt::Vertical` |\n| Session layout stored as JSON in `SessionConfig` | Recursive splitter tree + workspace IDs for full layout persistence |\n| Concrete ViewWindows live alongside the framework | `MarkdownViewWindow2`, `TextViewWindow2`, `PdfViewWindow2`, `MindMapViewWindow2`, and `WidgetViewWindow2` are registered with `ViewWindowFactory` per file type |\n\n### Directory Structure\n\n```\nsrc/\n├── main.cpp                # Entry point with DI wiring\n├── core/\n│   ├── servicelocator.h    # DI container\n│   ├── nodeidentifier.h    # Lightweight node ID (notebookId + relativePath)\n│   ├── services/           # Service layer (wraps vxcore)\n│   │   ├── configcoreservice.h/.cpp\n│   │   ├── notebookcoreservice.h/.cpp\n│   │   ├── searchcoreservice.h/.cpp\n│   │   ├── filetypecoreservice.h/.cpp\n│   │   ├── buffercoreservice.h/.cpp\n│   │   ├── bufferservice.h/.cpp    # Hook-aware wrapper, returns Buffer2\n│   │   ├── buffer2.h/.cpp          # Lightweight buffer handle (like QModelIndex)\n│   │   ├── templateservice.h/.cpp\n│   │   ├── historyservice.h/.cpp   # Aggregate per-notebook history across notebooks\n│   │   ├── workspacecoreservice.h/.cpp  # Workspace operations (split pane ↔ vxcore workspace)\n│   │   └── hookmanager.h/.cpp\n│   ├── hookcontext.h       # Hook callback context\n│   ├── hooknames.h         # Hook name constants\n│   ├── configmgr2.h/.cpp   # High-level config manager using DI\n│   └── iconfigmgr.h        # Interface for config managers\n├── gui/                    # GUI-aware services and utilities\n│   ├── services/\n│   │   ├── themeservice.h/.cpp         # GUI-aware theme management service\n│   │   └── viewwindowfactory.h/.cpp    # Registry mapping file types to ViewWindow2 creators\n│   └── utils/\n│       ├── widgetutils.h/.cpp          # Widget utility helpers\n│       ├── themeutils.h/.cpp           # Theme utility helpers\n│       ├── imageutils.h/.cpp           # Image utility helpers\n│       └── guiutils.h/.cpp             # General GUI utilities\n├── models/                 # Qt Model/View models\n│   ├── notebooknodemodel.h/.cpp       # QAbstractItemModel for node hierarchy\n│   └── notebooknodeproxymodel.h/.cpp  # Proxy model for sorting/filtering\n├── views/                  # Qt views and delegates\n│   ├── notebooknodeview.h/.cpp        # QTreeView for nodes\n│   ├── notebooknodedelegate.h/.cpp    # Item delegate for node rendering\n│   ├── combinednodeexplorer.h/.cpp    # Composite MVC wiring widget\n│   └── filenodedelegate.h/.cpp        # Item delegate for file list\n├── controllers/            # Controllers (business logic mediators)\n│   ├── notebooknodecontroller.h/.cpp  # Node operations controller\n│   ├── newnotecontroller.h/.cpp       # New note dialog controller\n│   ├── newfoldercontroller.h/.cpp     # New folder dialog controller\n│   ├── newnotebookcontroller.h/.cpp   # New notebook dialog controller\n│   ├── opennotebookcontroller.h/.cpp  # Open notebook flow controller\n│   ├── managenotebookscontroller.h/.cpp\n│   ├── importfoldercontroller.h/.cpp\n│   ├── recyclebincontroller.h/.cpp\n│   └── viewareacontroller.h/.cpp      # View area orchestrator (open/close/split/move)\n├── widgets/                # UI widgets (views receiving ServiceLocator&)\n│   ├── mainwindow2.h/.cpp  # Main window shell\n│   ├── notebookexplorer2.h/.cpp\n│   ├── notebookselector2.h/.cpp\n│   ├── toolbarhelper2.h/.cpp\n│   ├── viewwindow2.h/.cpp  # Abstract base for file viewer windows\n│   ├── markdownviewwindow2.h/.cpp  # Markdown editor/preview window\n│   ├── textviewwindow2.h/.cpp      # Plain text editor window\n│   ├── pdfviewwindow2.h/.cpp       # PDF viewer window\n│   ├── mindmapviewwindow2.h/.cpp   # Mind map viewer window\n│   ├── widgetviewwindow2.h/.cpp    # Generic widget-hosting window\n│   ├── viewsplit2.h/.cpp   # QTabWidget-based split pane (one vxcore workspace)\n│   ├── viewarea2.h/.cpp    # Splitter tree view (manages ViewSplit2 layout)\n│   └── dialogs/            # Dialog widgets\n│       ├── newnotedialog2.h/.cpp\n│       ├── newfolderdialog2.h/.cpp\n│       ├── newnotebookdialog2.h/.cpp\n│       ├── managenotebooksdialog2.h/.cpp\n│       └── importfolderdialog2.h/.cpp\n├── net/\n│   └── networkutils.h/.cpp # core_net: Qt Core/Network-only HTTP helpers\n│                           # (vnotex::NetworkUtils / NetworkReply / NetworkAccess)\n├── utils/\n│   └── fileutils2.h/.cpp   # File utilities\n└── ...\n```\n\n---\n\n## Source-Wide Qt Patterns\n\n### Memory Management\n```cpp\n// Use Qt smart pointers\nQScopedPointer<MainConfig> m_config;\nQSharedPointer<Task> task;\n\n// QObject parent-child for automatic cleanup\nm_themeMgr = new ThemeMgr(this);  // 'this' takes ownership\n```\n\n### Queued-Connection Metatypes on Qt 6\n\nQueued calls need copyable argument types known to Qt's metatype system. Keep\n`Q_DECLARE_METATYPE` and the existing typed `qRegisterMetaType<T>()` registrations for\ncustom values crossing threads. Qt 6's moc-generated signal metadata supplies parameter\ntypes directly; an unqualified signal spelling alone does **not** require an extra\n`qRegisterMetaType<T>(\"T\")` alias.\n\nString-based APIs still need their exact runtime type names. In particular, the existing\n`QMetaObject::invokeMethod(worker, \"method\", Qt::QueuedConnection, Q_ARG(T, value))` form\nmust resolve the spelling stringified by `Q_ARG`. If `Q_DECLARE_METATYPE(vnotex::T)`\nregisters the qualified name but `Q_ARG` spells `T`, retain the unqualified alias as well.\n\n`ImageHostService` registers `\"ImageHostWorkItem\"` for its `doUpload`, `doRemove` and\n`doTestConfig` worker dispatches. Keep that registration; real worker delivery is\ncovered by `tests/core/test_imagehostasync.cpp`.\nThe typed `SearchWorker` result/error and `ImageHostWorker` completion connections need\nno extra unqualified aliases. Real queued search delivery is covered by\n`testSimpleSearchUnicodeReplacement` and `testSearchContentStreamingBatchUnion` in\n`tests/core/test_searchservice.cpp`.\n\nDo not add aliases to every `Q_DECLARE_METATYPE` indiscriminately: inspect the actual\nstring-based callers, if any, before adding or removing a named registration.\n\n---\n\n## Related Modules\n\n| Module | File | Read this when |\n|--------|------|----------------|\n| Core & Services | [core/AGENTS.md](core/AGENTS.md) | ServiceLocator, DI, Buffer2, hooks, config, adding a service |\n| Services (deep) | [core/services/AGENTS.md](core/services/AGENTS.md) | Sync, save/search threading, update check, service internals |\n| Controllers | [controllers/AGENTS.md](controllers/AGENTS.md) | Adding or changing a controller |\n| Models | [models/AGENTS.md](models/AGENTS.md) | Qt Model/View data representations |\n| Views | [views/AGENTS.md](views/AGENTS.md) | View conventions, delegate patterns |\n| Widgets | [widgets/AGENTS.md](widgets/AGENTS.md) | Widget conventions, ViewArea2 framework, styling |\n| GUI Services | [gui/AGENTS.md](gui/AGENTS.md) | Theme, ViewWindowFactory, GUI utilities |\n| Utilities | [utils/AGENTS.md](utils/AGENTS.md) | PathUtils, HtmlUtils, FileUtils2 |\n| CI & Packaging | [../.github/AGENTS.md](../.github/AGENTS.md) | `src/Packaging.cmake`, Qt 6 deployment |\n","AGENTS.md":"# VNote Agent Development Guide\n\nRoot routing document: prerequisites, build, repo-wide rules, and an index of the module docs.\nModule-specific detail lives in the child `AGENTS.md` that owns the code — see\n[Module Documentation Index](#module-documentation-index).\n\n## Prerequisites\n\n- **Git** (with Git Bash on Windows)\n- **CMake** 3.21+\n- **Qt** 6.9+ (with QtWebEngine and Core5Compat)\n- **C++20-capable compiler** (MSVC, GCC, Clang) for the syntax indexer; VNote uses C++17\n- **Python 3** for syntax catalog generation\n- **clang-format** (optional, for automatic code formatting)\n\n## Setup\n\nAfter cloning the repository, run the init script:\n\n| Platform | Command |\n|----------|---------|\n| Linux/macOS | `bash scripts/init.sh` |\n| Windows | `scripts\\init.cmd` |\n\nThe init script:\n1. Initializes and updates git submodules recursively\n2. Installs pre-commit hook for automatic clang-format on staged C++ files\n3. Sets up vtextedit submodule pre-commit hook\n\n## Build Commands\n\n### Release Build\n```bash\nmkdir build && cd build\ncmake ..\ncmake --build . --config Release\n```\n\n### Debug Build\n```bash\nmkdir build && cd build\ncmake .. -DCMAKE_BUILD_TYPE=Debug\ncmake --build . --config Debug\n```\n\n### Windows (PowerShell)\n```powershell\nNew-Item -ItemType Directory -Force -Path build\nSet-Location build\ncmake .. -GNinja\ncmake --build . --config Release\n```\n\n### Clean Build\n```bash\nrm -rf build && mkdir build && cd build && cmake .. && cmake --build .\n```\nNever repair a stale build dir in place.\n\n### Building the Tests\nTests are **not** built by default (`VNOTE_BUILD_TESTS=OFF`), so a plain `cmake ..` produces the\napp only. CI enables them explicitly. To build them locally:\n```bash\ncmake .. -DVNOTE_BUILD_TESTS=ON\ncmake --build .\n```\n\nTesting (two suites, two build dirs): [tests/AGENTS.md](tests/AGENTS.md).\n\n### Pre-push verification (REQUIRED)\n\nBefore pushing parent-repository or submodule changes, agents MUST:\n\n1. Determine the affected build and test targets from **all unpushed changes** relative\n   to the destination branch, not just the latest commit. Trace shared headers/APIs,\n   build configuration and submodule pins to dependent consumers; filenames alone\n   are not sufficient. Broaden coverage when the impact cannot be bounded safely.\n2. Successfully build the affected application/library targets and **all test targets**\n   with tests enabled, using a compatible Qt kit, compiler and build configuration.\n   Include targets excluded from the default build and native-editor or standalone\n   submodule test targets in their own build directories. An app-only build does not\n   verify tests. Follow [tests/AGENTS.md](tests/AGENTS.md) for test build configuration.\n3. Verify the final code/configuration being pushed; rebuild required targets after\n   subsequent changes. Report the scope rationale, build commands, results, skips and\n   platform limits. If all unpushed changes are documentation-only, check the docs and\n   state that no build/test targets are affected; compilation and tests are not required.\n\n**Running tests is not required before pushing.** Successful compilation and linking of\nall test targets is sufficient for the local test verification gate; CI test requirements\nremain unchanged.\n\n**Do not push when required builds fail, time out or cannot run.** Fix the cause and\nverify again; never disable tests or exclude required targets just to obtain a successful\nbuild. If build verification is unavailable, report the exact blocker and obtain an\nexplicit user exception before pushing. A local build is not proof that tests pass or\nthat other operating systems or packaging gates pass; disclose that limitation.\n\n### CI runner debugging (SSH)\n\nWhen a failure needs the hosted runner, follow the key-restricted SSH procedure in\n[CI debugging](.github/AGENTS.md#interactive-ci-debugging). Dispatch a non-publishing\nbranch at the intended revision, preserve first-run evidence, and confirm session closure.\nAn ad-hoc debug pass is not proof that normal CI is fixed.\n\n---\n\n## Submodule Push Discipline (CRITICAL — read before every push)\n\nVNote pins git submodules (`libs/vxcore`, `libs/vtextedit`, `libs/QHotkey`, `libs/qwindowkit`)\nto specific commits. **CI clones submodules from their own remotes**, so a commit that exists\nonly locally fails every CI job at \"Init Submodules\" (`upload-pack: not our ref <sha>`) before\nany build runs.\n\n### Rule: ALWAYS push the submodule FIRST, then the parent repo.\n\n```bash\ncd libs/vxcore\ngit push origin HEAD:main      # or the appropriate branch\ncd ../..\ngit push                       # only now may the gitlink be pushed\n```\nTo push both at once with verification, use `git push --recurse-submodules=on-demand`, or enforce\nit once per clone:\n\n```bash\ngit config push.recurseSubmodules check   # aborts the parent push if a submodule commit is unpushed\n```\n\n### Before pushing, verify no submodule commit is stranded\n\n```bash\n# For each submodule, confirm local HEAD is not ahead of its remote:\ngit submodule foreach 'git status -sb'\n# A line like \"## main...origin/main [ahead 1]\" means an UNPUSHED submodule commit — push it before pushing vnote.\n\n# Confirm the pinned SHA exists on the submodule remote:\ncd libs/vxcore && git branch -r --contains $(git rev-parse HEAD) && cd ../..\n# Empty output = the commit is NOT on any remote branch yet. DO NOT push vnote until it is.\n```\n\nIf CI is already failing with \"not our ref\", the fix is to push the missing submodule commit (do\n**not** roll back the parent pointer if newer parent commits depend on the new submodule API).\n\n### cmark is vendored twice — bump both pins together\n\nThe same cmark fork (`https://github.com/vnotex/cmark.git`) is pinned by two different submodules:\n\n| Parent submodule | Nested cmark path |\n|---|---|\n| `libs/vtextedit` | `libs/cmark` |\n| `libs/vxcore` | `third_party/cmark` |\n\nOnly **one** is ever compiled in: `libs/CMakeLists.txt` adds `vtextedit` first, which defines the\n`cmark` target unconditionally, so vxcore's `if(NOT TARGET cmark)` guard skips its own copy.\nDiverged pins silently build vxcore against an untested cmark, with no error or warning.\n**Bump both submodules to the same cmark commit in a single change.** [`tests/utils/test_cmark_pin_drift.cpp`](tests/utils/test_cmark_pin_drift.cpp) fails the\nbuild when the two recorded pins differ.\n\n---\n\n## Architecture Overview\n\nVNote uses a **clean architecture** with **Model-View-Controller (MVC)** pattern and dependency\ninjection. Models hold data, Views display it, Controllers handle logic, Services own domain\noperations, and every layer receives a `ServiceLocator&` — there are no singletons.\n\n| Layer | Location | Responsibility | Example |\n|-------|----------|----------------|---------|\n| **Model** | `src/models/` | Data representation, Qt Model/View integration | `NotebookNodeModel` exposes node hierarchy via `QAbstractItemModel` |\n| **View** | `src/views/` | Display data, capture user input, emit signals | `NotebookNodeView` renders tree, emits `nodeActivated` signal |\n| **Controller** | `src/controllers/` | Handle actions, orchestrate Model/View, business logic | `NotebookNodeController` handles new/delete/rename operations |\n| **Service** | `src/core/services/` | Domain operations, data access via vxcore | `NotebookCoreService` wraps vxcore C API for notebook CRUD |\n\n### MVC Rules (MUST FOLLOW)\n\n| Rule | Rationale |\n|------|-----------|\n| **Models MUST NOT** contain UI logic | Models are reusable across different views |\n| **Views MUST NOT** modify data directly | Views only display and emit signals |\n| **Controllers MUST NOT** inherit from QWidget | Controllers are testable without GUI |\n| **All layers receive `ServiceLocator&`** | Enables dependency injection and testing |\n| **Use signals/slots between layers** | Loose coupling between M, V, C |\n\nFull diagram, directory tree, design-decision rationale (including the ViewArea2 framework), and\nsource-wide Qt patterns: see [src/AGENTS.md](src/AGENTS.md).\n\n---\n\n## Code Style Guidelines\n\n### Standards\n- **C++17** standard\n- **Qt 6** framework\n- CMake with `CMAKE_AUTOMOC`, `CMAKE_AUTOUIC`, `CMAKE_AUTORCC` enabled\n\n### Formatting\n- 2-space indentation\n- 100 character line limit\n- Pointer alignment right: `int *ptr`, not `int* ptr`\n- Use provided `.clang-format` (auto-applied via pre-commit hook)\n\n### No Hardcoded Colors (enforced)\n\n**Never write a literal color into a `setStyleSheet()` call.** VNote ships 12\nthemes, 6 of them dark; a hardcoded `#RRGGBB`, `rgb()/rgba()` literal, or CSS\ncolor name is correct only in whichever theme its author was running, and it\ncannot follow a runtime theme switch.\n\n`tests/utils/test_hardcoded_color_drift.cpp` is a grep gate over `src/` that\nfails the build on any **stylesheet string** literal containing both a CSS color\nproperty and a literal color value (colors used as *data*, and `QColor` painted\nin a `paintEvent`, are out of scope).\n\nUse `InlineBanner`, the `SeverityText` / `MutedText` dynamic properties, a rule\nin each theme's `interface.qss`, or `ThemeService::paletteColor()`. Do **not**\nuse `setEnabled(false)` to mute text. See\n[src/widgets/AGENTS.md § No Hardcoded Colors in C++](src/widgets/AGENTS.md#no-hardcoded-colors-in-c)\nfor the decision table and the escape hatch.\n\n### Tooltip Copy\n\nTooltip text MUST NOT end with sentence-ending punctuation. Keep punctuation needed between\nsentences or clauses; write parenthetical qualifiers such as `(restart required)` in lowercase\nwithout terminal punctuation.\n\n### Naming Conventions\n\n| Element | Convention | Example |\n|---------|------------|---------|\n| Classes | CamelCase | `ConfigMgr`, `MainWindow` |\n| Methods | camelCase | `getInst()`, `initLoad()` |\n| Parameters | `p_` prefix | `p_parent`, `p_config` |\n| Members | `m_` prefix | `m_themeMgr`, `m_config` |\n| Constants | `c_` prefix | `c_orgName`, `c_appName` |\n| Getters | `get` prefix | `getThemeMgr()`, `getName()` |\n\n### Include Order\n```cpp\n#include \"ownheader.h\"      // Own header first\n\n#include <QDateTime>        // Qt includes\n#include <QObject>\n\n#include \"localheader.h\"    // Local includes\n#include <core/configmgr.h>\n#include <utils/utils.h>\n\nusing namespace vnotex;     // Namespace declaration in .cpp\n```\n\n### Header Guards\n```cpp\n#ifndef CLASSNAME_H\n#define CLASSNAME_H\n// ...\n#endif // CLASSNAME_H\n```\n\n### Namespaces\n\nVNote uses a single `vnotex` namespace. Services that wrap the vxcore C library use the `CoreService` suffix to distinguish them from higher-level wrapper services:\n\n| Class Pattern | Purpose | Examples |\n|---------------|---------|----------|\n| `XXXCoreService` | Low-level services that wrap the vxcore C library (hold `VxCoreContextHandle`) | `ConfigCoreService`, `NotebookCoreService`, `BufferCoreService`, `SearchCoreService`, `FileTypeCoreService` |\n| Other classes | Everything else: UI, controllers, models, hook-aware wrapper services | `BufferService` (hook wrapper), `HookManager`, `TemplateService`, `ConfigMgr2`, controllers, widgets |\n\n**Rules:**\n- `using namespace vnotex;` in `.cpp` files only, never in headers\n- Forward declarations preferred in headers\n\n### Signal/Slot Connections\n```cpp\n// Preferred: typed signal/slot syntax\nconnect(m_taskMgr, &TaskMgr::taskOutputRequested,\n        this, &VNoteX::showOutputRequested);\n\n// With overloaded methods\nconnect(this, &VNoteX::openNodeRequested, m_bufferMgr,\n        QOverload<Node *, const QSharedPointer<FileOpenParameters> &>::of(&BufferMgr::open));\n```\n\nMemory management, queued-connection metatype registration, and the rest of\nthe source-wide patterns: [src/AGENTS.md § Source-Wide Qt Patterns](src/AGENTS.md#source-wide-qt-patterns).\nNoncopyable, `VNOTEX_DEPRECATED` and exception handling: [src/core/AGENTS.md](src/core/AGENTS.md#core-c-facilities).\n\n---\n\n## Sync State Model\n\nNotebook sync has 8 reachable states (S0-S7), defined by the tuple of on-disk JSON sync fields,\ncredential presence in the OS keychain (Git PAT or WebDAV login), and runtime registration in vxcore's `states_` map. **S5 is the\nonly \"ready\" state**; S1-S4 and S6 are partial/inconsistent, S0 is cleanly disabled, S7 is\nin-flight. Every controller, widget, and service that touches sync must reason in these terms.\n\nFull predicate table, recovery paths, reconcile semantics, disable cleanup, the S6 startup sweep,\nand the Qt-side scheduling shape:\n[src/core/services/AGENTS.md § Sync State Model](src/core/services/AGENTS.md#sync-state-model).\nvxcore-side threading contract:\n[libs/vxcore/src/sync/AGENTS.md](libs/vxcore/src/sync/AGENTS.md).\n---\n\n## Save Path Threading Contract\n\nBuffer saves run on a worker via `BufferSaveQueue`; save and git-stage/commit work on the SAME\nnotebook are serialized by the per-notebook `NotebookIoGate` async mutex. WebDAV additionally installs incoming changes under\nthe gate after a GUI-owned buffer reservation and save drain; metadata refresh remains GUI-thread-only.\n\n> **Forbidden Patterns (post-T7):**\n> - Calling `vxcore_buffer_save` directly from the UI thread. Use [`BufferSaveQueue::enqueue`](src/core/services/buffersavequeue.h) instead.\n> - Touching a notebook's working tree (save, stage, commit, checkout) without holding `NotebookIoGate::ScopedLock(notebookId)`.\n\nFull rationale and the two-phase sync gate:\n[src/core/services/AGENTS.md § Save Path Threading Contract](src/core/services/AGENTS.md#save-path-threading-contract).\n\n---\n\n## Cross-Notebook Node Transfer\n\nCross-notebook copy and move operations must go through\n[`NodeTransferService`](src/core/services/nodetransferservice.h). VNote owns editor/comment\ndurability, sync exclusion, IO-gate ordering, hooks, clipboard policy, progress, and UI feedback;\nvxcore owns snapshotting, identity regeneration, storage transactions, recovery, and mutation facts.\nControllers and widgets must not call the vxcore transfer API directly. Full orchestration contract:\n[src/core/services/AGENTS.md § Cross-Notebook Node Transfer](src/core/services/AGENTS.md#cross-notebook-node-transfer).\n\n---\n\n## Search Threading Contract\n\nContent search in vxcore owns NO thread pool: it enqueues one work item per file-chunk onto the\n`\"vxcore.search\"` work queue, and the CALLER owns the drain policy (VNote's `SearchService` runs\nthe drain pool; the initiating thread help-drains, which is the single-threaded correctness\nfloor).\n\nFull contract: [src/core/services/AGENTS.md § Search Threading Contract](src/core/services/AGENTS.md#search-threading-contract).\n\n---\n\n## Update Check\n\nOrdinary startup and manual checks fetch release metadata and offer the **release page**.\n`UpdateService` and `UpdateInfo` remain check-only: no asset selection, download, verification\nor installation is added to them.\n\n> **An ordinary check never modifies the install directory or downloads release artifacts.**\n> It creates no lease, staging tree or journal, and extracts or executes nothing. Its only\n> writes are the `lastUpdateCheckTime` / `skippedUpdateVersion` config values. Startup must\n> remain usable from read-only installations (`/usr/bin`, Program Files, a read-only DMG;\n> issue #2728), without probing install-directory writability.\n\nThe sole installation exception is an **explicit Windows “Update Now” notification action**\nlaunching the deployed external PowerShell script. Keep “Check Release” first and available;\nthe manual-check dialog is unchanged and has no automatic-install entry point. The action\nmust disclose downloading, closing and reopening VNote; checks never activate it themselves.\n\nThe external updater must authenticate the offered version's signed full-package manifest\nwith the bundled minisign verifier before using it, then verify the archive and payload.\nIt consumes no deltas, never falls back to another source or unsigned artifacts, and requests\nshutdown only after preparation succeeds. A launch-scoped, PID/token-authenticated pipe and\nan explicit accepted close authorize installation; cancellation or pipe EOF does not. Wait\nfor the original process to exit, including save/sync drains, without force-killing it.\n\nPreserve portable `config` and all unowned files by cloning the stopped installation and\noverlaying verified payload files, then swapping sibling directories. Reject path/reparse\nhazards and incompatible collisions; retain a complete recovery backup and restore it if\nthe second rename fails. Protected installations require manual-update guidance, **never\nelevation, ACL changes or dependency installation**. Reopen with the helper's inherited token;\nreport installed-but-not-reopened separately. Two renames are not power-loss atomic.\n\nRepo-wide forbidden patterns (also constrain CI, packaging, controllers and widgets):\n\n- **Never** download, extract, execute or install a release artifact during an ordinary check.\n- **Never** write outside the configuration directory as part of an update check.\n- **Never** read `assets[]` in `UpdateService`; only the explicit external updater selects assets.\n- **Never** give `UpdateService` a `ConfigMgr2` dependency — add policy to the controller.\n- **Never** restore the C++ installer, delta application or a general-purpose quit CLI/protocol.\n\nThe Windows release publishes a signed full-package manifest and may also publish\nunused delta ZIPs. Signing and external-verifier contract: [docs/update-signing.md](docs/update-signing.md).\nController launch/shutdown ownership: [src/controllers/AGENTS.md § Update Check](src/controllers/AGENTS.md#update-check).\nEndpoints, source-scoped redirects and check threading:\n[src/core/services/AGENTS.md § Update Check](src/core/services/AGENTS.md#update-check).\n\n---\n\n## Logging\n\nUse Qt logging macros:\n```cpp\nqDebug() << \"Debug message\";\nqInfo() << \"Info message\";\nqWarning() << \"Warning message\";\nqCritical() << \"Critical error\";\n```\n\n## Code Formatting\n\nThe pre-commit hook automatically formats staged C++ files using clang-format.\n\n**Manual formatting:**\n```bash\nclang-format -i src/core/myfile.cpp\n```\n\n**Excluded from formatting:** `libs/` directory (third-party code)\n\n### Format before staging and committing\n\nAgents MUST format intended C++ changes with the repository configuration and the same formatter\nas the pre-commit hook **before staging**. Review the resulting diff and preserve unrelated edits.\nPrefer explicit `git add -- <paths>` followed by ordinary `git commit` when the index contains only\nintended changes. If other work must remain staged, a path-limited commit (`git commit --only` or\n`git commit <paths>`) must not rely on the hook for further formatting: verify the formatter would\nmake no additional changes first.\n\nPath-limited commits use a temporary index. The hook's `clang-format -i` and `git add` can update\nthe working file and committed snapshot while leaving the normal index with older formatting.\nAfter such a commit, compare HEAD, index and working tree for the committed paths; do not blindly\nclassify `MM` leftovers as user edits and keep restoring them.\n\nClean up only residue proven to come from the agent's workflow: when `git diff HEAD -- <path>` is\nempty and staged/unstaged formatting hunks are exact inverses, `git restore --staged -- <path>`\nresynchronizes the index without changing the working file. An empty HEAD diff alone does not\njustify clearing intentional user staging. Never use blanket reset/restore or stash for cleanup.\n\n---\n\n## Shared JSON Keys (SSOT)\n\nCross-boundary JSON keys (vxcore↔Qt) live in `<vxcore/notebook_json_keys.h>`.\nSee [`libs/vxcore/AGENTS.md` § JSON Conventions](libs/vxcore/AGENTS.md#json-conventions)\nfor the SSOT contract and the `test_json_key_drift` regression gate.\n\n---\n\n## Module Documentation Index\n\nDetailed knowledge for each module lives in its own AGENTS.md.\n\n**Where to write new documentation:** default to the child `AGENTS.md` that owns the code (create\none for the directory if it does not exist yet). The root doc is injected into *every* agent turn,\nso anything added here costs context on turns that will never need it. Add to root only when the\nknowledge is genuinely repo-wide — i.e. it constrains callers who will never load the owning\nmodule's doc (as the MVC rules and the update-install invariant do), or it is a build/setup/style\nrule that applies everywhere. Even then, keep root to a short normative summary plus a link, and\nput the full detail in the module doc.\n\n| Module | File | Read this when |\n|--------|------|----------------|\n| Source overview | [src/AGENTS.md](src/AGENTS.md) | You need the architecture diagram, directory tree, design-decision rationale, or a source-wide Qt pattern (memory, queued metatypes) |\n| Core & Services | [src/core/AGENTS.md](src/core/AGENTS.md) | ServiceLocator, DI, Buffer2, hooks, config, themes, adding a service |\n| Services (deep) | [src/core/services/AGENTS.md](src/core/services/AGENTS.md) | Sync state model, save/search threading, update check, notifications |\n| Controllers | [src/controllers/AGENTS.md](src/controllers/AGENTS.md) | Adding or changing a controller; MVC rules for controllers |\n| Models | [src/models/AGENTS.md](src/models/AGENTS.md) | Qt Model/View data representations |\n| Views | [src/views/AGENTS.md](src/views/AGENTS.md) | View conventions, delegate patterns |\n| Widgets | [src/widgets/AGENTS.md](src/widgets/AGENTS.md) | Widget conventions, ViewArea2 framework, styling, construction pattern |\n| GUI Services | [src/gui/AGENTS.md](src/gui/AGENTS.md) | Theme, ViewWindowFactory, GUI utilities |\n| Utilities | [src/utils/AGENTS.md](src/utils/AGENTS.md) | PathUtils, HtmlUtils, FileUtils2 reference |\n| pdf.js assets | [src/data/extra/web/pdf.js/AGENTS.md](src/data/extra/web/pdf.js/AGENTS.md) | Touching the vendored PDF viewer or its Qt 6.9+ WebEngine requirement |\n| Testing | [tests/AGENTS.md](tests/AGENTS.md) | Agent GUI smoke testing with `--no-single-instance`, test suites, test mode, fixtures, coverage |\n| CI & Packaging | [.github/AGENTS.md](.github/AGENTS.md) | Workflows, SSH runner debugging, `src/Packaging.cmake`, Qt 6 release packages |\n| vxcore (submodule) | [libs/vxcore/AGENTS.md](libs/vxcore/AGENTS.md) | C library: notebook/config/search backend |\n| vxcore Sync | [libs/vxcore/src/sync/AGENTS.md](libs/vxcore/src/sync/AGENTS.md) | Pluggable sync backend interface (ISyncBackend, SyncManager) |\n| vtextedit (submodule) | [libs/vtextedit/AGENTS.md](libs/vtextedit/AGENTS.md) | Qt editor widget library |\n",".github/AGENTS.md":"# CI, Packaging & Release\n\nThis directory holds the GitHub Actions workflows. **This document also governs\n[`src/Packaging.cmake`](../src/Packaging.cmake)**, which lives outside this directory and\ntherefore will not auto-load this file — open it explicitly when touching packaging, the\nWindows Qt 6 packages, or updater artifacts.\n\nSee also: [../AGENTS.md](../AGENTS.md) (repo-wide rules, submodule push discipline) and\n[../src/AGENTS.md](../src/AGENTS.md) (architecture, source-wide Qt patterns).\n\n---\n\n## Build execution\n\nLinux and macOS set `CMAKE_BUILD_PARALLEL_LEVEL=2` for both packaging and test\nbuilds; Make otherwise builds these large targets serially. Keep Cargo's separate\ntwo-job limit. In test-enabled jobs, run both parent and native-editor suites even when one fails,\nthen fail the step if either returned nonzero. Preserve both original CTest logs\nand the Typst JUnit reports before diagnostics; macOS artifact names include the Qt kit.\nUpload reports for successful test steps too: a QtTest case can skip inside an otherwise\npassing executable, so a green CTest result alone does not prove native input was exercised.\nDiagnostic reruns require the test step itself to have failed and a nonempty\n`Testing/Temporary/LastTestsFailed.log` in that suite's build tree. Parent and\nnative-editor histories are independent: `ctest --rerun-failed` without its own\nfailure history can rerun a passing suite after an unrelated failure.\n\nLinux, Windows and macOS allow 180 minutes for cold builds, full native test suites,\npackage gates and artifact publication. Keep the individual CTest deadlines; the job\nbudget is not a test timeout.\nmacOS creates and unlocks its disposable test keychain only after test compilation,\nright before running CTest. Cold builds must not consume the vault's unlock lease;\nrestore the original keychain before final packaging and signing.\n\nThe macOS matrix enables unit/native GUI tests only for Qt 6.10.3. Qt 6.9.3 sets\n`VNOTE_BUILD_TESTS=OFF` and skips the test build, execution and disposable test keychain.\nThe existing `[Release]` condition disables these suites for both kits. Both Qt kits still\nbuild and sign packages and run the signed-package ABI, consumer and WebEngine gates,\nincluding native Intel verification; do not describe Qt 6.9.3 as unit-test validated.\n\n## Interactive CI debugging\n\nUse this when a failure needs the real hosted OS, desktop, Qt kit or CI setup.\n`workflow_dispatch` accepts `debug_enabled=true` plus a single `debug_public_key`\n(`ssh-ed25519`). `.github/scripts/debug-session.py` uses pinned Upterm 0.39.0 and the\npinned `uptermd.upterm.dev` host-certificate authority. There is no web-shell or\nanonymous fallback. Never put a private key into inputs, logs or artifacts.\n\n### Dispatch from the local checkout\n\nThe examples use PowerShell 7.3+, an authenticated GitHub CLI, OpenSSH and Python.\nStart at the repository root, at the committed revision you intend to investigate.\nCreate a disposable key outside the checkout; keep diagnostic evidence outside this\nkey directory so it survives key cleanup:\n\n```powershell\n$debugDir = Join-Path ([IO.Path]::GetTempPath()) ('vnote-ci-ssh-' + [guid]::NewGuid().ToString('N'))\nNew-Item -ItemType Directory -Path $debugDir | Out-Null\n$key = Join-Path $debugDir 'id_ed25519'\nssh-keygen -q -t ed25519 -N '' -C vnote-ci-debug -f $key\nif ($LASTEXITCODE -ne 0) { throw 'Key generation failed' }\n$publicKey = (Get-Content -LiteralPath \"$key.pub\" -Raw).Trim()\n```\n\nUse a non-`master` branch at that revision. Debugging on `master` can resume into\ncontinuous-release publication with ad-hoc runner edits; debug mode does not disable\npublication. Follow the submodule push discipline before publishing the branch:\n\n```powershell\n$debugBranch = 'ci-debug-' + (git rev-parse --short HEAD).Trim()\ngit push --recurse-submodules=check origin \"HEAD:refs/heads/$debugBranch\"\nif ($LASTEXITCODE -ne 0) { throw 'Debug branch push failed' }\ngh workflow run ci-win.yml --ref $debugBranch `\n  -f debug_enabled=true -f \"debug_public_key=$publicKey\"\nif ($LASTEXITCODE -ne 0) { throw 'Debug dispatch failed' }\n```\n\nChoose the workflow and connection artifact for the required platform:\n\n| Workflow | Connection artifact | Pause point |\n|----------|---------------------|-------------|\n| `ci-win.yml` | `debug-connection-windows` | After tools, before configure/build |\n| `ci-linux.yml` | `debug-connection-linux` | After configure, before build and test dependencies |\n| `ci-macos.yml` | `debug-connection-macos-<Qt version>` | After configure, before test build/keychain setup |\n\nmacOS build jobs also use the `Mac-code-sign` environment. Any configured approval or\nbranch policy applies before SSH setup; inspect a waiting/blocked job before treating\na missing connection artifact as a helper failure. Do not switch to `master` to bypass\nan environment restriction.\n\nSet `$runId` to the ID in the dispatched run URL. If the CLI did not return a URL,\nuse `gh run list --workflow ci-win.yml --branch $debugBranch --event workflow_dispatch`\nand match the intended revision, not an unrelated latest run. Confirm `headSha` and\n`headBranch` with `gh run view $runId --json headSha,headBranch,jobs`.\n\nDownload once the connection-upload step succeeds; the artifact is available while\nthe job is still running. Do not wait for workflow completion to obtain it. Each\nmacOS matrix job has its own session/artifact, including the Qt 6.9 job whose unit\nsuite is disabled. Account for every paused job. No guest within 15 minutes ends a\nsession; the normal job deadline also continues to run.\n\n```powershell\n$connectionDir = Join-Path $debugDir 'connection'\ngh run download $runId --name debug-connection-windows --dir $connectionDir\nif ($LASTEXITCODE -ne 0) { throw 'Connection artifact download failed' }\n$connection = Get-Content (Join-Path $connectionDir 'connection.json') -Raw | ConvertFrom-Json\nssh-keygen -lf \"$key.pub\" -E sha256\n$connection.keyFingerprint\n```\n\nCompare the `SHA256:` fingerprint tokens, not the entire lines: the key comments can\ndiffer. The artifact contains `sshCommand`, `joinCommand`, `keyFingerprint` and\n`restoreEnvironment`; it contains no private key. Use fresh metadata for this run,\nnot an expired session address from an old log.\n\n### Connect with the pinned relay authority\n\nCreate a dedicated `known_hosts` from the checked-in helper's pin rather than trusting\n`ssh-keyscan`, accepting a new host key interactively, or disabling host-key checks.\n`runpy` below loads the definitions without invoking the helper's guarded `main()`:\n\n```powershell\n$knownHosts = (Join-Path $debugDir 'known_hosts').Replace('\\', '/')\npython -c \"import runpy,sys;sys.stdout.write(runpy.run_path('.github/scripts/debug-session.py')['KNOWN_HOSTS'])\" |\n  Set-Content -LiteralPath $knownHosts -Encoding ascii\nif ($LASTEXITCODE -ne 0) { throw 'Relay pin extraction failed' }\nif ($connection.sshCommand -notmatch '^ssh ([A-Za-z0-9_-]+@uptermd\\.upterm\\.dev)$') {\n  throw 'Unexpected SSH command; inspect connection.json without executing its contents'\n}\n$destination = $Matches[1]\n$sshArgs = @(\n  '-F', 'none', '-i', $key,\n  '-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes', '-o', 'IdentityAgent=none',\n  '-o', 'ForwardAgent=no', '-o', 'StrictHostKeyChecking=yes',\n  '-o', ('UserKnownHostsFile=\"' + $knownHosts + '\"'), '-o', 'GlobalKnownHostsFile=none'\n)\nssh -tt @sshArgs $destination\n```\n\nThis attaches to the hosted shell (`pwsh -NoProfile` on Windows, clean Bash elsewhere),\ninitially in `GITHUB_WORKSPACE`. For agent automation, use a named PTY session and\nsend commands to that shell; wait for an explicit completion marker and exit code.\nA live PID, quiet terminal, or SSH disconnect does not prove command completion.\n\n### Restore the environment and reproduce the workflow\n\nThe helper removes checkout/submodule HTTP auth and credential-bearing URL rewrites,\nscrubs token/secret/key variables from the hosted process, and retains runner orphan\ntracking. Do not inject credentials back into the session to bypass this isolation.\nArtifact/signing steps keep their normal environment and run after session cleanup.\n\nUpterm redirects three XDG variables for its own state. Before measuring application\nbehavior, restore the original values in `connection.json`'s `restoreEnvironment`;\nJSON `null` means unset, not an empty or literal `null` value. On the Windows runner:\n\n```powershell\n$metadata = Join-Path $env:RUNNER_TEMP 'vnote-debug/connection.json'\n$original = (Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json).restoreEnvironment\nforeach ($entry in $original.PSObject.Properties) {\n  if ($null -eq $entry.Value) {\n    Remove-Item -LiteralPath ('Env:' + $entry.Name) -ErrorAction SilentlyContinue\n  } else {\n    [Environment]::SetEnvironmentVariable($entry.Name, $entry.Value, 'Process')\n  }\n}\n$build = Join-Path (Split-Path $env:GITHUB_WORKSPACE -Parent) 'build'\n```\n\nOn Bash, use `unset` for null entries and `export` with the recorded values otherwise.\nRestore only the hosted shell's application environment, not the helper's state files.\n\nRead the checked-out workflow's configure/build/test steps and reproduce their commands,\nworking directories and **step-local environment**. The build directory is the sibling\n`build` of `GITHUB_WORKSPACE`, not a directory inside the checkout.\n\n- **Windows:** the debug shell is not a Visual Studio developer shell. The earlier\n  `vcvars64.bat` invocation affected only its own `cmd` process; `VS_CMD` and\n  `VNOTE_TESTS` from later steps are not already set. Use the matrix's toolchain and\n  import its environment into the hosted shell, or run the entire command chain in\n  an initialized `cmd`. Match Release/Ninja and `VNOTE_BUILD_TESTS=ON` for test work.\n- **Linux:** test dependencies, D-Bus/keyring setup and the Xvfb/native-platform wrapper\n  come after the pause. Reproduce the workflow and `.github/scripts/run-linux-tests.sh`,\n  rather than replacing native GUI tests with `offscreen` or `minimal`.\n- **macOS:** preserve the configured Qt kit/architectures, normalize `TMPDIR` as the\n  workflow does, and create/unlock the disposable test keychain after compilation.\n  The Qt 6.9 configuration does not register the unit/native suites.\n- **Package-dependent tests:** build/package/extract prerequisites in the original\n  order. For example, Windows updater fixtures need the packaged installation; a\n  target-only build is not equivalent to the full CI setup. A debug-shell-only failure\n  (including PowerShell module lookup) is not automatically the original regression.\n\n### Preserve evidence before rerunning\n\nIn the build directory, inspect `ctest --show-only=json-v1` for the executable, arguments, working\ndirectory and test environment. Build the relevant target, then run its registered\nCTest command without changing assertions, deadlines or platform behavior. A focused\nWindows example, after the workflow's configure/toolchain prerequisites:\n\n```powershell\ncmake --build $build --target test_themeservice\nif ($LASTEXITCODE -ne 0) { throw 'Theme test build failed' }\nctest --test-dir $build -C Release -R '^test_themeservice$' --output-on-failure --no-tests=error\n$testExit = $LASTEXITCODE\nWrite-Output \"DEBUG_CTEST_EXIT=$testExit\"\n```\n\nCapture the **first** failure to files before standalone repetitions or full-order\nexperiments overwrite it. Keep CTest's `Testing/Temporary/Last*.log`, QtTest text/JUnit\nreports, exit code, revision and relevant environment. The Windows theme registration\nalready writes `tests/gui/test_themeservice.txt` and `.xml` in the build tree. For other\nQtTest executables, use unique `-o <file>,txt` and `-o <file>,junitxml` outputs while\nretaining `-o -,txt` and the registered runtime environment. File loggers cannot explain\na failure before QtTest initializes. Inspect skips as well as pass counts.\n\nExport evidence while the session is reachable: from another local terminal with the\nsame client variables, use `sftp @sshArgs $destination` (without SSH's `-tt`) and download\nthe files to a separate evidence directory. Resolve remote paths with SFTP's `pwd`/`ls`\ninstead of guessing Windows drive mappings. Ad-hoc files are not automatically uploaded\nby the workflow; normal report-upload steps run only after the debug pause ends.\n\nA relay disconnect can leave the runner and build alive. Check the run's job state and\nreattach to the same session if still reachable; inspect the saved completion marker\nand files instead of restarting the build blindly. If standalone runs pass, compare\nnormal CI setup/order before proposing production or timing changes. Ad-hoc debug\npasses do not replace a clean CI run of the committed fix.\n\n### Close the session and remove the key\n\nRun `exit 0` **inside the hosted shell** to end it and resume the workflow. Closing the\nlocal SSH client alone merely disconnects; it does not end the pause. Close each macOS\nmatrix session you opened and account for any other paused matrix jobs.\n\nIf the relay cannot be recovered, cancel the job with `gh run cancel $runId`, then\nawait shutdown with `gh run watch $runId --exit-status` (cancellation returns nonzero).\nVerify the `Close SSH debug session` step succeeded via `gh run view $runId --json jobs`;\ndo not assume a lost connection means the exposed session is gone. Once closure is\nconfirmed and evidence is saved outside `$debugDir`, remove the local disposable key:\n\n```powershell\nRemove-Item -LiteralPath $debugDir -Recurse -Force\n```\n\n## Release packaging dependencies\n\n`pack` must build `cmark_exe` before CPack runs: upstream cmark installs its CLI\nunconditionally, even though Windows package pruning removes it afterward. Release\nCI deliberately skips `all` to avoid building tests, so do not rely on a prior\nall-target build or weaken the install/pruning checks to hide a missing executable.\n\n### Native Typst runtime and attribution\n\nVNote requires the pinned Rust 1.99.0/Typst 0.15.1 shared library and Qt PDF. Embedding forces\n`VTEXTEDIT_ENABLE_TYPST=ON` and `VTEXTEDIT_BUILD_DEMOS=OFF`; do not ship upstream demos.\n`vnote_typst_runtime` always refreshes the native runtime (and VTextEdit.dll on Windows),\neven without relinking VNote. Both `pack` and direct `deploy` depend on this target and\n`prepare_typst_notices`, retaining the existing cmark/lrelease prerequisites.\n\nProvision `cargo +1.99.0 install cargo-about --locked --version 0.9.2 --features cli` and\nfetch the backend's complete locked dependency graph before offline deployment. The `cli`\nfeature is required: installing this release without it produces no executable. Keep\n`CARGO_BUILD_JOBS=2` and OS/Rust/lockfile-specific Cargo caches; Apple keys also include\nthe deployment target. Non-release native editor tests run from `build/libs/vtextedit`\nwith `--no-tests=error`, not just the parent's CTest root.\n\n`package/prepare-typst-notices.py` uses `package/typst-about.toml` to generate full Rust\nlicense texts, including verbatim typst-assets/font notices and the backend LGPL text.\nMatching `sbom/qtpdf-<version>.spdx.json` and\n`sbom/qtwebengine-chromium-pdf-<version>.spdx.json` are required SDK attribution inputs.\nQt 6.9.3 provides both when qtpdf and qtwebengine are installed; the Chromium PDF inventory\nis in qtwebengine. Missing/empty inputs fail packaging; never substitute another Qt version.\nGeneration is deployment-only, with no dependency installation at configure or runtime.\nNotices ship in Windows `licenses/typst`, Linux `share/licenses/vnote/typst`, and macOS\n`Contents/Resources/licenses/typst`.\n\nWindows deployment scans both vnote and VTextEdit for Qt6Pdf and keeps\n`vtextedit_typst.dll` in the flat runtime directory, never the pruned lib/bin directories.\nLinux scans the installed `libvtextedit_typst.so.1` as a linuxdeploy library and checks\ninstall/deploy exit codes. Universal macOS libraries build both Rust triples with the job's\ndeployment target, then lipo once. Stage `libvtextedit_typst.1.dylib` explicitly in\n`Contents/Frameworks` through vnote_typst_runtime/deploy, fix load paths and sign before\ndeep bundle signing; ordinary install() does not assemble the VNote app. Resolve the\npackaged VTextEdit filename from\nVNote's linked dependency, not an unversioned dylib name; rpath cleanup and final DMG checks\nmust inspect that same library. Remove only rpaths outside the final DMG path allowlist.\nInspect both slices' minimum-OS metadata and reject build-tree dependencies. Metadata from a newer runner is not a Monterey launch test.\nRun `scripts/e2e/smoke_typst_package.py --library <extracted-library>` against final package\ncontents without developer Cargo/Qt/Typst overrides. It calls C ABI 1 in a supervised\nPython test worker and verifies real PDF/heading output and reply lifetime after session\ndestruction. Also launch the packaged consumer and inspect native dependency closure;\nctypes alone does not prove VTextEdit/VNote linkage. Reject the old Typst executable.\nOn macOS, architecture probes and ABI workers reuse the gate's already-running Python\n(`sys.executable`), not a different `/usr/bin/python3` runtime. Keep the 15-second architecture\nprobe, mandatory native-architecture execution, and existing non-host availability policy.\n`scripts/e2e/smoke_macos_consumer.py` requires the expected native architecture and launches\nVNote with the unchanged 30-second budget and an isolated home. Test-enabled builds run ARM\nnative suites; all builds retain both available ABI slices and the WebEngine gate. Separate\n`macos-15-intel` jobs download\nthe same signed DMGs and require native x86_64 ABI and consumer execution for both Qt kits.\nCold Rosetta translation of QtWebEngineCore can exceed the launch budget before main; do not\nwarm its cache, retry to green, or increase the deadline instead of using native Intel proof.\nStaged DMG artifacts are inputs to verification, not publication approval. Both continuous and\nversioned publication depend on all ARM build/gates and both Intel verification jobs, and\npublish the unchanged signed artifacts. Native macOS 15 Intel proof is not a Monterey test.\nA packaged-consumer timeout remains a failure. Capture bounded native stack/screen diagnostics\nunder `RUNNER_TEMP`, terminate only that unreaped probe's own process group, and retain the\nmatrix-specific diagnostic artifact. Include open-file, memory-map and bounded read-only\nRosetta-daemon samples to locate pre-main loader stalls; pre-create privileged sample output\nas the runner user. A separate one-day `failed-package-macos-<Qt version>` artifact retains\nonly the exact signed DMG for reproduction, never adjacent certificates/keychains or the build\ndirectory. It is diagnostic-only, not a release. Do not mask the timeout, force-unmount a busy\npackage, or move SSH sessions past signing-secret provisioning.\n\n## macOS runtime composition\n\nRun `package/prune-macos-package.cmake` with `VNOTE_APP_DIR` after each deployment\npass and after the last CI framework fixup, **before signing**. CPack's external\nmacOS packager rewrites the build bundle; Windows install-staging pruning does not\napply. Complete all test builds before final `pack`/deployment: those builds can relink\nVNote/VTextEdit and restore build-tree RPATHs. Both framework-fixup passes precede the\nfinal RPATH cleanup and pruning; no rebuild follows before signing. Use `VERIFY_ONLY=ON`\non the mounted signed DMG; it must never mutate it.\n\nRemove `PlugIns/imageformats/libqpdf.dylib`, `PlugIns/position/libqtposition_nmea.dylib`\n(and their `_debug` variants), `PlugIns/sqldrivers`, `QtSql.framework`, and\n`QtSerialPort.framework`. VNote and its widget tests must not require QtSql; vxcore\nuses native SQLite instead. Keep qtserialport in the CI SDK so macdeployqt can scan\nthe NMEA plugin before pruning. SQL-driver Homebrew libraries/workarounds are not needed.\nKeep QtPdf for Typst, QtPositioning and other positioning plugins, TLS backends,\nQML/Quick/WebEngine, DevTools, universal slices and attribution files.\n\nOnly `en-US.pak`, `zh-CN.pak`, `ja.pak`, and `ko.pak` ship in\n`Contents/Frameworks/QtWebEngineCore.framework/Versions/A/Resources/qtwebengine_locales`.\nMissing/empty retained packs or another locale directory fail packaging. Do not\nprune sibling WebEngine resource paks, ICU data, snapshots or the process helper.\nQt's resource fallback supplies English for unsupported locales without changing\nVNote's application locale or date/number formatting.\n\nThe final-DMG gate rejects load commands to the removed frameworks in **all**\nMach-O files. `scripts/e2e/smoke_webengine_package.py` compiles a disposable native\nprobe against the SDK but runs against the signed package: separate processes for\neach supported locale plus German/British-English fallback, actual DOM rendering,\nand mapped framework/locale paths in both browser and helper. It must not resolve\nruntime resources from the SDK or ship its probe in the app.\nBuild the probe with Qt's imported CMake targets, including their transitive header modules,\nnot a hand-maintained framework/include list. Declare the ARM yield intrinsic before Qt headers\non ARM hosts. Configure and compile share one 120-second budget; target only the host architecture.\nUse `BUILD_WITH_INSTALL_RPATH` with the signed package Frameworks directory and no SDK link-path\nRPATH additions. Keep the 45-second render and 15-second shutdown bounds and all locale checks.\n\n## Windows runtime (Qt 6)\n\nWindows packages use Qt 6 only and publish the `win64` artifact family. Qt uses\nits native Schannel TLS backend; Git sync uses WinHTTP. Do not bundle OpenSSL\nDLLs or a separate UCRT redistributable: supported Windows versions provide UCRT.\nKeep the MSVC runtime deployment and its fallback for newer toolsets, the flat\npackage layout, and the SDK/stray-executable composition gates. WiX stages the\napplication under `Unspecified/`; pruning resolves that component root before\nchecking for `vnote.exe`. ZIP staging remains flat.\n\nDeploy both VNote and VTextEdit without `--no-pdf`: Typst requires `Qt6Pdf.dll`.\nPrune only the unused `imageformats/qpdf.dll` plugin and NMEA position plugin after\ndeployment (including their debug variants); NMEA needs SerialPort, and Qt 6.10.3\nstill deploys it with `--exclude-plugins`. Keep `Qt6Positioning.dll`, the other\npositioning plugins, and WebEngine's QML/Quick/OpenGL dependencies.\nDo not prune the dynamically loaded D3D/DXC compilers or the MSVC runtime set.\nThe extracted-ZIP composition gate rejects PDF image plugins, NMEA, and application-local\nUCRT/API-set DLLs recursively; the Typst gate requires a nonempty `Qt6Pdf.dll`.\nKeep `qtpdf` in the CI SDK for the required runtime and its attribution.\n---\n\n## Update artifacts\n\nOrdinary startup/manual checks remain metadata-only and read-only for the installation;\n`UpdateService` ignores assets. The sole installation entry point is the explicit Windows\n`Update Now` notification action launching the deployed external PowerShell updater, not an\nin-process installer or a manual-dialog action. The script consumes signed **full packages\nonly**, with no unsigned or cross-source fallback. See\n[../docs/update-signing.md](../docs/update-signing.md) and root\n[AGENTS.md § Update Check](../AGENTS.md#update-check).\n\nOn the existing `[Release]` master-push condition, `win64` must publish a ZIP,\n`.manifest.json` and `.manifest.json.minisig`. Retain optional delta publication,\nalthough this updater never consumes deltas. Preserve the stable-channel and\nartifact-list gates. Missing `MINISIGN_SECRET_KEY` must fail release signing, and\nsecret files stay outside the workspace. Missing Gitee mirrors fail closed at\nruntime; never switch the user's source to GitHub.\n\n`prepare_win_updater` must run `package/prepare-win-updater.cmake` with\n`UPDATER_OUTPUT_DIR=<build>/src/win-updater` as a **deploy-only prerequisite**: no network on\nconfigure or ordinary builds. Pin minisign 0.11 archive, executable and upstream ISC license\nby SHA-256; accept only hash-matching cache entries and extract only in the build tree.\nSigning uses this prepared verifier, not a second unpinned download.\n\nInstall `update-vnote.ps1`, `minisign.exe` and `LICENSE.minisign` under `updater/`, all\nnon-optional; retain the existing libsodium notices. Keep the root executable allowlist at\n`vnote.exe` and `QtWebEngineProcess.exe`. Require `vtextedit_typst.dll` separately.\nThe extracted-package gate must\nassert all three helper files and run the **packaged** `updater/minisign.exe -v`,\nprepending that package root to the subprocess PATH for its runtime DLLs. Retain\nall Qt 6 package-composition checks; verifier presence alone is not runtime proof.\n\n## Linux test runtime\n\n`ci-linux.yml` installs test-only runtime dependencies after packaging and runs\nboth the suite and failure diagnostics through `.github/scripts/run-linux-tests.sh`.\nThe wrapper creates a private D-Bus session and unlocked GNOME keyring, verifies\nSecret Service with a write/read/delete probe, then tears down the daemon and\nprivate XDG directories. CTest runs under Xvfb so tests selecting the native xcb\nbackend have a display. Install `xvfb` and `xauth` unconditionally: the final AppImage\nconsumer check also needs them on release builds. It launches packaged `AppRun --help`\nwith `QT_QPA_PLATFORM=xcb` under `xvfb-run -a`; the AppImage ships xcb, not offscreen.\nKeep Qt lookup inside the extracted package and fail on plugin/display/load errors.\nThe child `test_typsteditor` suite must select `QT_QPA_PLATFORM=xcb` explicitly:\nits native layout/raster scenarios must not inherit the parent's offscreen default.\nKeep the credential tests on a real vault; headers from\n`libsecret-1-dev` alone do not provide one.\n\nTLS fixtures use Ubuntu's `/usr/bin/openssl` explicitly, not a PATH executable\nshadowed by the separate packaging OpenSSL install. These environment changes\napply only to tests; release commits skip vault setup and test invocations, not the\nXvfb-backed packaged-consumer check.\n\n## macOS test temporary paths\n\nBoth non-release test launches resolve `TMPDIR` to its physical path before\nstarting Qt or Python fixtures. The system `/var` alias resolves to `/private/var`\nand otherwise trips the intentional sync path-safety checks. Keep those guards\nand deliberate symlinks inside test fixtures unchanged; only the outer temporary\nroot is normalized, without affecting build or packaging paths.\n"},"items":[{"name":"AGENTS.md","path":".github/AGENTS.md","rawUrl":"https://raw.githubusercontent.com/vnotex/vnote/HEAD/.github/AGENTS.md","title":".github - AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"# CI, Packaging & Release\n\nThis directory holds the GitHub Actions workflows. **This document also governs\n[`src/Packaging.cmake`](../src/Packaging.cmake)**, which lives outside this directory and\ntherefore will not auto-load this file — open it explicitly when touching packaging, the\nWindows Qt 6 packages, or updater artifacts.\n\nSee also: [../AGENTS.md](../AGENTS.md) (repo-wide rules, submodule push discipline) and\n[../src/AGENTS.md](../src/AGENTS.md) (architecture, source-wide Qt patterns).\n\n---\n\n## Build execution\n\nLinux and macOS set `CMAKE_BUILD_PARALLEL_LEVEL=2` for both packaging and test\nbuilds; Make otherwise builds these large targets serially. Keep Cargo's separate\ntwo-job limit. In test-enabled jobs, run both parent and native-editor suites even when one fails,\nthen fail the step if either returned nonzero. Preserve both original CTest logs\nand the Typst JUnit reports before diagnostics; macOS artifact names include the Qt kit.\nUpload reports for successful test steps too: a QtTest case can skip inside an otherwise\npassing executable, so a green CTest result alone does not prove native input was exercised.\nDiagnostic reruns require the test step itself to have failed and a nonempty\n`Testing/Temporary/LastTestsFailed.log` in that suite's build tree. Parent and\nnative-editor histories are independent: `ctest --rerun-failed` without its own\nfailure history can rerun a passing suite after an unrelated failure.\n\nLinux, Windows and macOS allow 180 minutes for cold builds, full native test suites,\npackage gates and artifact publication. Keep the individual CTest deadlines; the job\nbudget is not a test timeout.\nmacOS creates and unlocks its disposable test keychain only after test compilation,\nright before running CTest. Cold builds must not consume the vault's unlock lease;\nrestore the original keychain before final packaging and signing.\n\nThe macOS matrix enables unit/native GUI tests only for Qt 6.10.3. Qt 6.9.3 sets\n`VNOTE_BUILD_TESTS=OFF` and skips the test build, execution and disposable test keychain.\nThe existing `[Release]` condition disables these suites for both kits. Both Qt kits still\nbuild and sign packages and run the signed-package ABI, consumer and WebEngine gates,\nincluding native Intel verification; do not describe Qt 6.9.3 as unit-test validated.\n\n## Interactive CI debugging\n\nUse this when a failure needs the real hosted OS, desktop, Qt kit or CI setup.\n`workflow_dispatch` accepts `debug_enabled=true` plus a single `debug_public_key`\n(`ssh-ed25519`). `.github/scripts/debug-session.py` uses pinned Upterm 0.39.0 and the\npinned `uptermd.upterm.dev` host-certificate authority. There is no web-shell or\nanonymous fallback. Never put a private key into inputs, logs or artifacts.\n\n### Dispatch from the local checkout\n\nThe examples use PowerShell 7.3+, an authenticated GitHub CLI, OpenSSH and Python.\nStart at the repository root, at the committed revision you intend to investigate.\nCreate a disposable key outside the checkout; keep diagnostic evidence outside this\nkey directory so it survives key cleanup:\n\n```powershell\n$debugDir = Join-Path ([IO.Path]::GetTempPath()) ('vnote-ci-ssh-' + [guid]::NewGuid().ToString('N'))\nNew-Item -ItemType Directory -Path $debugDir | Out-Null\n$key = Join-Path $debugDir 'id_ed25519'\nssh-keygen -q -t ed25519 -N '' -C vnote-ci-debug -f $key\nif ($LASTEXITCODE -ne 0) { throw 'Key generation failed' }\n$publicKey = (Get-Content -LiteralPath \"$key.pub\" -Raw).Trim()\n```\n\nUse a non-`master` branch at that revision. Debugging on `master` can resume into\ncontinuous-release publication with ad-hoc runner edits; debug mode does not disable\npublication. Follow the submodule push discipline before publishing the branch:\n\n```powershell\n$debugBranch = 'ci-debug-' + (git rev-parse --short HEAD).Trim()\ngit push --recurse-submodules=check origin \"HEAD:refs/heads/$debugBranch\"\nif ($LASTEXITCODE -ne 0) { throw 'Debug branch push failed' }\ngh workflow run ci-win.yml --ref $debugBranch `\n  -f debug_enabled=true -f \"debug_public_key=$publicKey\"\nif ($LASTEXITCODE -ne 0) { throw 'Debug dispatch failed' }\n```\n\nChoose the workflow and connection artifact for the required platform:\n\n| Workflow | Connection artifact | Pause point |\n|----------|---------------------|-------------|\n| `ci-win.yml` | `debug-connection-windows` | After tools, before configure/build |\n| `ci-linux.yml` | `debug-connection-linux` | After configure, before build and test dependencies |\n| `ci-macos.yml` | `debug-connection-macos-<Qt version>` | After configure, before test build/keychain setup |\n\nmacOS build jobs also use the `Mac-code-sign` environment. Any configured approval or\nbranch policy applies before SSH setup; inspect a waiting/blocked job before treating\na missing connection artifact as a helper failure. Do not switch to `master` to bypass\nan environment restriction.\n\nSet `$runId` to the ID in the dispatched run URL. If the CLI did not return a URL,\nuse `gh run list --workflow ci-win.yml --branch $debugBranch --event workflow_dispatch`\nand match the intended revision, not an unrelated latest run. Confirm `headSha` and\n`headBranch` with `gh run view $runId --json headSha,headBranch,jobs`.\n\nDownload once the connection-upload step succeeds; the artifact is available while\nthe job is still running. Do not wait for workflow completion to obtain it. Each\nmacOS matrix job has its own session/artifact, including the Qt 6.9 job whose unit\nsuite is disabled. Account for every paused job. No guest within 15 minutes ends a\nsession; the normal job deadline also continues to run.\n\n```powershell\n$connectionDir = Join-Path $debugDir 'connection'\ngh run download $runId --name debug-connection-windows --dir $connectionDir\nif ($LASTEXITCODE -ne 0) { throw 'Connection artifact download failed' }\n$connection = Get-Content (Join-Path $connectionDir 'connection.json') -Raw | ConvertFrom-Json\nssh-keygen -lf \"$key.pub\" -E sha256\n$connection.keyFingerprint\n```\n\nCompare the `SHA256:` fingerprint tokens, not the entire lines: the key comments can\ndiffer. The artifact contains `sshCommand`, `joinCommand`, `keyFingerprint` and\n`restoreEnvironment`; it contains no private key. Use fresh metadata for this run,\nnot an expired session address from an old log.\n\n### Connect with the pinned relay authority\n\nCreate a dedicated `known_hosts` from the checked-in helper's pin rather than trusting\n`ssh-keyscan`, accepting a new host key interactively, or disabling host-key checks.\n`runpy` below loads the definitions without invoking the helper's guarded `main()`:\n\n```powershell\n$knownHosts = (Join-Path $debugDir 'known_hosts').Replace('\\', '/')\npython -c \"import runpy,sys;sys.stdout.write(runpy.run_path('.github/scripts/debug-session.py')['KNOWN_HOSTS'])\" |\n  Set-Content -LiteralPath $knownHosts -Encoding ascii\nif ($LASTEXITCODE -ne 0) { throw 'Relay pin extraction failed' }\nif ($connection.sshCommand -notmatch '^ssh ([A-Za-z0-9_-]+@uptermd\\.upterm\\.dev)$') {\n  throw 'Unexpected SSH command; inspect connection.json without executing its contents'\n}\n$destination = $Matches[1]\n$sshArgs = @(\n  '-F', 'none', '-i', $key,\n  '-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes', '-o', 'IdentityAgent=none',\n  '-o', 'ForwardAgent=no', '-o', 'StrictHostKeyChecking=yes',\n  '-o', ('UserKnownHostsFile=\"' + $knownHosts + '\"'), '-o', 'GlobalKnownHostsFile=none'\n)\nssh -tt @sshArgs $destination\n```\n\nThis attaches to the hosted shell (`pwsh -NoProfile` on Windows, clean Bash elsewhere),\ninitially in `GITHUB_WORKSPACE`. For agent automation, use a named PTY session and\nsend commands to that shell; wait for an explicit completion marker and exit code.\nA live PID, quiet terminal, or SSH disconnect does not prove command completion.\n\n### Restore the environment and reproduce the workflow\n\nThe helper removes checkout/submodule HTTP auth and credential-bearing URL rewrites,\nscrubs token/secret/key variables from the hosted process, and retains runner orphan\ntracking. Do not inject credentials back into the session to bypass this isolation.\nArtifact/signing steps keep their normal environment and run after session cleanup.\n\nUpterm redirects three XDG variables for its own state. Before measuring application\nbehavior, restore the original values in `connection.json`'s `restoreEnvironment`;\nJSON `null` means unset, not an empty or literal `null` value. On the Windows runner:\n\n```powershell\n$metadata = Join-Path $env:RUNNER_TEMP 'vnote-debug/connection.json'\n$original = (Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json).restoreEnvironment\nforeach ($entry in $original.PSObject.Properties) {\n  if ($null -eq $entry.Value) {\n    Remove-Item -LiteralPath ('Env:' + $entry.Name) -ErrorAction SilentlyContinue\n  } else {\n    [Environment]::SetEnvironmentVariable($entry.Name, $entry.Value, 'Process')\n  }\n}\n$build = Join-Path (Split-Path $env:GITHUB_WORKSPACE -Parent) 'build'\n```\n\nOn Bash, use `unset` for null entries and `export` with the recorded values otherwise.\nRestore only the hosted shell's application environment, not the helper's state files.\n\nRead the checked-out workflow's configure/build/test steps and reproduce their commands,\nworking directories and **step-local environment**. The build directory is the sibling\n`build` of `GITHUB_WORKSPACE`, not a directory inside the checkout.\n\n- **Windows:** the debug shell is not a Visual Studio developer shell. The earlier\n  `vcvars64.bat` invocation affected only its own `cmd` process; `VS_CMD` and\n  `VNOTE_TESTS` from later steps are not already set. Use the matrix's toolchain and\n  import its environment into the hosted shell, or run the entire command chain in\n  an initialized `cmd`. Match Release/Ninja and `VNOTE_BUILD_TESTS=ON` for test work.\n- **Linux:** test dependencies, D-Bus/keyring setup and the Xvfb/native-platform wrapper\n  come after the pause. Reproduce the workflow and `.github/scripts/run-linux-tests.sh`,\n  rather than replacing native GUI tests with `offscreen` or `minimal`.\n- **macOS:** preserve the configured Qt kit/architectures, normalize `TMPDIR` as the\n  workflow does, and create/unlock the disposable test keychain after compilation.\n  The Qt 6.9 configuration does not register the unit/native suites.\n- **Package-dependent tests:** build/package/extract prerequisites in the original\n  order. For example, Windows updater fixtures need the packaged installation; a\n  target-only build is not equivalent to the full CI setup. A debug-shell-only failure\n  (including PowerShell module lookup) is not automatically the original regression.\n\n### Preserve evidence before rerunning\n\nIn the build directory, inspect `ctest --show-only=json-v1` for the executable, arguments, working\ndirectory and test environment. Build the relevant target, then run its registered\nCTest command without changing assertions, deadlines or platform behavior. A focused\nWindows example, after the workflow's configure/toolchain prerequisites:\n\n```powershell\ncmake --build $build --target test_themeservice\nif ($LASTEXITCODE -ne 0) { throw 'Theme test build failed' }\nctest --test-dir $build -C Release -R '^test_themeservice$' --output-on-failure --no-tests=error\n$testExit = $LASTEXITCODE\nWrite-Output \"DEBUG_CTEST_EXIT=$testExit\"\n```\n\nCapture the **first** failure to files before standalone repetitions or full-order\nexperiments overwrite it. Keep CTest's `Testing/Temporary/Last*.log`, QtTest text/JUnit\nreports, exit code, revision and relevant environment. The Windows theme registration\nalready writes `tests/gui/test_themeservice.txt` and `.xml` in the build tree. For other\nQtTest executables, use unique `-o <file>,txt` and `-o <file>,junitxml` outputs while\nretaining `-o -,txt` and the registered runtime environment. File loggers cannot explain\na failure before QtTest initializes. Inspect skips as well as pass counts.\n\nExport evidence while the session is reachable: from another local terminal with the\nsame client variables, use `sftp @sshArgs $destination` (without SSH's `-tt`) and download\nthe files to a separate evidence directory. Resolve remote paths with SFTP's `pwd`/`ls`\ninstead of guessing Windows drive mappings. Ad-hoc files are not automatically uploaded\nby the workflow; normal report-upload steps run only after the debug pause ends.\n\nA relay disconnect can leave the runner and build alive. Check the run's job state and\nreattach to the same session if still reachable; inspect the saved completion marker\nand files instead of restarting the build blindly. If standalone runs pass, compare\nnormal CI setup/order before proposing production or timing changes. Ad-hoc debug\npasses do not replace a clean CI run of the committed fix.\n\n### Close the session and remove the key\n\nRun `exit 0` **inside the hosted shell** to end it and resume the workflow. Closing the\nlocal SSH client alone merely disconnects; it does not end the pause. Close each macOS\nmatrix session you opened and account for any other paused matrix jobs.\n\nIf the relay cannot be recovered, cancel the job with `gh run cancel $runId`, then\nawait shutdown with `gh run watch $runId --exit-status` (cancellation returns nonzero).\nVerify the `Close SSH debug session` step succeeded via `gh run view $runId --json jobs`;\ndo not assume a lost connection means the exposed session is gone. Once closure is\nconfirmed and evidence is saved outside `$debugDir`, remove the local disposable key:\n\n```powershell\nRemove-Item -LiteralPath $debugDir -Recurse -Force\n```\n\n## Release packaging dependencies\n\n`pack` must build `cmark_exe` before CPack runs: upstream cmark installs its CLI\nunconditionally, even though Windows package pruning removes it afterward. Release\nCI deliberately skips `all` to avoid building tests, so do not rely on a prior\nall-target build or weaken the install/pruning checks to hide a missing executable.\n\n### Native Typst runtime and attribution\n\nVNote requires the pinned Rust 1.99.0/Typst 0.15.1 shared library and Qt PDF. Embedding forces\n`VTEXTEDIT_ENABLE_TYPST=ON` and `VTEXTEDIT_BUILD_DEMOS=OFF`; do not ship upstream demos.\n`vnote_typst_runtime` always refreshes the native runtime (and VTextEdit.dll on Windows),\neven without relinking VNote. Both `pack` and direct `deploy` depend on this target and\n`prepare_typst_notices`, retaining the existing cmark/lrelease prerequisites.\n\nProvision `cargo +1.99.0 install cargo-about --locked --version 0.9.2 --features cli` and\nfetch the backend's complete locked dependency graph before offline deployment. The `cli`\nfeature is required: installing this release without it produces no executable. Keep\n`CARGO_BUILD_JOBS=2` and OS/Rust/lockfile-specific Cargo caches; Apple keys also include\nthe deployment target. Non-release native editor tests run from `build/libs/vtextedit`\nwith `--no-tests=error`, not just the parent's CTest root.\n\n`package/prepare-typst-notices.py` uses `package/typst-about.toml` to generate full Rust\nlicense texts, including verbatim typst-assets/font notices and the backend LGPL text.\nMatching `sbom/qtpdf-<version>.spdx.json` and\n`sbom/qtwebengine-chromium-pdf-<version>.spdx.json` are required SDK attribution inputs.\nQt 6.9.3 provides both when qtpdf and qtwebengine are installed; the Chromium PDF inventory\nis in qtwebengine. Missing/empty inputs fail packaging; never substitute another Qt version.\nGeneration is deployment-only, with no dependency installation at configure or runtime.\nNotices ship in Windows `licenses/typst`, Linux `share/licenses/vnote/typst`, and macOS\n`Contents/Resources/licenses/typst`.\n\nWindows deployment scans both vnote and VTextEdit for Qt6Pdf and keeps\n`vtextedit_typst.dll` in the flat runtime directory, never the pruned lib/bin directories.\nLinux scans the installed `libvtextedit_typst.so.1` as a linuxdeploy library and checks\ninstall/deploy exit codes. Universal macOS libraries build both Rust triples with the job's\ndeployment target, then lipo once. Stage `libvtextedit_typst.1.dylib` explicitly in\n`Contents/Frameworks` through vnote_typst_runtime/deploy, fix load paths and sign before\ndeep bundle signing; ordinary install() does not assemble the VNote app. Resolve the\npackaged VTextEdit filename from\nVNote's linked dependency, not an unversioned dylib name; rpath cleanup and final DMG checks\nmust inspect that same library. Remove only rpaths outside the final DMG path allowlist.\nInspect both slices' minimum-OS metadata and reject build-tree dependencies. Metadata from a newer runner is not a Monterey launch test.\nRun `scripts/e2e/smoke_typst_package.py --library <extracted-library>` against final package\ncontents without developer Cargo/Qt/Typst overrides. It calls C ABI 1 in a supervised\nPython test worker and verifies real PDF/heading output and reply lifetime after session\ndestruction. Also launch the packaged consumer and inspect native dependency closure;\nctypes alone does not prove VTextEdit/VNote linkage. Reject the old Typst executable.\nOn macOS, architecture probes and ABI workers reuse the gate's already-running Python\n(`sys.executable`), not a different `/usr/bin/python3` runtime. Keep the 15-second architecture\nprobe, mandatory native-architecture execution, and existing non-host availability policy.\n`scripts/e2e/smoke_macos_consumer.py` requires the expected native architecture and launches\nVNote with the unchanged 30-second budget and an isolated home. Test-enabled builds run ARM\nnative suites; all builds retain both available ABI slices and the WebEngine gate. Separate\n`macos-15-intel` jobs download\nthe same signed DMGs and require native x86_64 ABI and consumer execution for both Qt kits.\nCold Rosetta translation of QtWebEngineCore can exceed the launch budget before main; do not\nwarm its cache, retry to green, or increase the deadline instead of using native Intel proof.\nStaged DMG artifacts are inputs to verification, not publication approval. Both continuous and\nversioned publication depend on all ARM build/gates and both Intel verification jobs, and\npublish the unchanged signed artifacts. Native macOS 15 Intel proof is not a Monterey test.\nA packaged-consumer timeout remains a failure. Capture bounded native stack/screen diagnostics\nunder `RUNNER_TEMP`, terminate only that unreaped probe's own process group, and retain the\nmatrix-specific diagnostic artifact. Include open-file, memory-map and bounded read-only\nRosetta-daemon samples to locate pre-main loader stalls; pre-create privileged sample output\nas the runner user. A separate one-day `failed-package-macos-<Qt version>` artifact retains\nonly the exact signed DMG for reproduction, never adjacent certificates/keychains or the build\ndirectory. It is diagnostic-only, not a release. Do not mask the timeout, force-unmount a busy\npackage, or move SSH sessions past signing-secret provisioning.\n\n## macOS runtime composition\n\nRun `package/prune-macos-package.cmake` with `VNOTE_APP_DIR` after each deployment\npass and after the last CI framework fixup, **before signing**. CPack's external\nmacOS packager rewrites the build bundle; Windows install-staging pruning does not\napply. Complete all test builds before final `pack`/deployment: those builds can relink\nVNote/VTextEdit and restore build-tree RPATHs. Both framework-fixup passes precede the\nfinal RPATH cleanup and pruning; no rebuild follows before signing. Use `VERIFY_ONLY=ON`\non the mounted signed DMG; it must never mutate it.\n\nRemove `PlugIns/imageformats/libqpdf.dylib`, `PlugIns/position/libqtposition_nmea.dylib`\n(and their `_debug` variants), `PlugIns/sqldrivers`, `QtSql.framework`, and\n`QtSerialPort.framework`. VNote and its widget tests must not require QtSql; vxcore\nuses native SQLite instead. Keep qtserialport in the CI SDK so macdeployqt can scan\nthe NMEA plugin before pruning. SQL-driver Homebrew libraries/workarounds are not needed.\nKeep QtPdf for Typst, QtPositioning and other positioning plugins, TLS backends,\nQML/Quick/WebEngine, DevTools, universal slices and attribution files.\n\nOnly `en-US.pak`, `zh-CN.pak`, `ja.pak`, and `ko.pak` ship in\n`Contents/Frameworks/QtWebEngineCore.framework/Versions/A/Resources/qtwebengine_locales`.\nMissing/empty retained packs or another locale directory fail packaging. Do not\nprune sibling WebEngine resource paks, ICU data, snapshots or the process helper.\nQt's resource fallback supplies English for unsupported locales without changing\nVNote's application locale or date/number formatting.\n\nThe final-DMG gate rejects load commands to the removed frameworks in **all**\nMach-O files. `scripts/e2e/smoke_webengine_package.py` compiles a disposable native\nprobe against the SDK but runs against the signed package: separate processes for\neach supported locale plus German/British-English fallback, actual DOM rendering,\nand mapped framework/locale paths in both browser and helper. It must not resolve\nruntime resources from the SDK or ship its probe in the app.\nBuild the probe with Qt's imported CMake targets, including their transitive header modules,\nnot a hand-maintained framework/include list. Declare the ARM yield intrinsic before Qt headers\non ARM hosts. Configure and compile share one 120-second budget; target only the host architecture.\nUse `BUILD_WITH_INSTALL_RPATH` with the signed package Frameworks directory and no SDK link-path\nRPATH additions. Keep the 45-second render and 15-second shutdown bounds and all locale checks.\n\n## Windows runtime (Qt 6)\n\nWindows packages use Qt 6 only and publish the `win64` artifact family. Qt uses\nits native Schannel TLS backend; Git sync uses WinHTTP. Do not bundle OpenSSL\nDLLs or a separate UCRT redistributable: supported Windows versions provide UCRT.\nKeep the MSVC runtime deployment and its fallback for newer toolsets, the flat\npackage layout, and the SDK/stray-executable composition gates. WiX stages the\napplication under `Unspecified/`; pruning resolves that component root before\nchecking for `vnote.exe`. ZIP staging remains flat.\n\nDeploy both VNote and VTextEdit without `--no-pdf`: Typst requires `Qt6Pdf.dll`.\nPrune only the unused `imageformats/qpdf.dll` plugin and NMEA position plugin after\ndeployment (including their debug variants); NMEA needs SerialPort, and Qt 6.10.3\nstill deploys it with `--exclude-plugins`. Keep `Qt6Positioning.dll`, the other\npositioning plugins, and WebEngine's QML/Quick/OpenGL dependencies.\nDo not prune the dynamically loaded D3D/DXC compilers or the MSVC runtime set.\nThe extracted-ZIP composition gate rejects PDF image plugins, NMEA, and application-local\nUCRT/API-set DLLs recursively; the Typst gate requires a nonempty `Qt6Pdf.dll`.\nKeep `qtpdf` in the CI SDK for the required runtime and its attribution.\n---\n\n## Update artifacts\n\nOrdinary startup/manual checks remain metadata-only and read-only for the installation;\n`UpdateService` ignores assets. The sole installation entry point is the explicit Windows\n`Update Now` notification action launching the deployed external PowerShell updater, not an\nin-process installer or a manual-dialog action. The script consumes signed **full packages\nonly**, with no unsigned or cross-source fallback. See\n[../docs/update-signing.md](../docs/update-signing.md) and root\n[AGENTS.md § Update Check](../AGENTS.md#update-check).\n\nOn the existing `[Release]` master-push condition, `win64` must publish a ZIP,\n`.manifest.json` and `.manifest.json.minisig`. Retain optional delta publication,\nalthough this updater never consumes deltas. Preserve the stable-channel and\nartifact-list gates. Missing `MINISIGN_SECRET_KEY` must fail release signing, and\nsecret files stay outside the workspace. Missing Gitee mirrors fail closed at\nruntime; never switch the user's source to GitHub.\n\n`prepare_win_updater` must run `package/prepare-win-updater.cmake` with\n`UPDATER_OUTPUT_DIR=<build>/src/win-updater` as a **deploy-only prerequisite**: no network on\nconfigure or ordinary builds. Pin minisign 0.11 archive, executable and upstream ISC license\nby SHA-256; accept only hash-matching cache entries and extract only in the build tree.\nSigning uses this prepared verifier, not a second unpinned download.\n\nInstall `update-vnote.ps1`, `minisign.exe` and `LICENSE.minisign` under `updater/`, all\nnon-optional; retain the existing libsodium notices. Keep the root executable allowlist at\n`vnote.exe` and `QtWebEngineProcess.exe`. Require `vtextedit_typst.dll` separately.\nThe extracted-package gate must\nassert all three helper files and run the **packaged** `updater/minisign.exe -v`,\nprepending that package root to the subprocess PATH for its runtime DLLs. Retain\nall Qt 6 package-composition checks; verifier presence alone is not runtime proof.\n\n## Linux test runtime\n\n`ci-linux.yml` installs test-only runtime dependencies after packaging and runs\nboth the suite and failure diagnostics through `.github/scripts/run-linux-tests.sh`.\nThe wrapper creates a private D-Bus session and unlocked GNOME keyring, verifies\nSecret Service with a write/read/delete probe, then tears down the daemon and\nprivate XDG directories. CTest runs under Xvfb so tests selecting the native xcb\nbackend have a display. Install `xvfb` and `xauth` unconditionally: the final AppImage\nconsumer check also needs them on release builds. It launches packaged `AppRun --help`\nwith `QT_QPA_PLATFORM=xcb` under `xvfb-run -a`; the AppImage ships xcb, not offscreen.\nKeep Qt lookup inside the extracted package and fail on plugin/display/load errors.\nThe child `test_typsteditor` suite must select `QT_QPA_PLATFORM=xcb` explicitly:\nits native layout/raster scenarios must not inherit the parent's offscreen default.\nKeep the credential tests on a real vault; headers from\n`libsecret-1-dev` alone do not provide one.\n\nTLS fixtures use Ubuntu's `/usr/bin/openssl` explicitly, not a PATH executable\nshadowed by the separate packaging OpenSSL install. These environment changes\napply only to tests; release commits skip vault setup and test invocations, not the\nXvfb-backed packaged-consumer check.\n\n## macOS test temporary paths\n\nBoth non-release test launches resolve `TMPDIR` to its physical path before\nstarting Qt or Python fixtures. The system `/var` alias resolves to `/private/var`\nand otherwise trips the intentional sync path-safety checks. Keep those guards\nand deliberate symlinks inside test fixtures unchanged; only the outer temporary\nroot is normalized, without affecting build or packaging paths.\n","isInternal":false,"tokens":6038,"sizeBytes":26283},{"name":"AGENTS.md","path":"AGENTS.md","rawUrl":"https://raw.githubusercontent.com/vnotex/vnote/HEAD/AGENTS.md","title":"AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"# VNote Agent Development Guide\n\nRoot routing document: prerequisites, build, repo-wide rules, and an index of the module docs.\nModule-specific detail lives in the child `AGENTS.md` that owns the code — see\n[Module Documentation Index](#module-documentation-index).\n\n## Prerequisites\n\n- **Git** (with Git Bash on Windows)\n- **CMake** 3.21+\n- **Qt** 6.9+ (with QtWebEngine and Core5Compat)\n- **C++20-capable compiler** (MSVC, GCC, Clang) for the syntax indexer; VNote uses C++17\n- **Python 3** for syntax catalog generation\n- **clang-format** (optional, for automatic code formatting)\n\n## Setup\n\nAfter cloning the repository, run the init script:\n\n| Platform | Command |\n|----------|---------|\n| Linux/macOS | `bash scripts/init.sh` |\n| Windows | `scripts\\init.cmd` |\n\nThe init script:\n1. Initializes and updates git submodules recursively\n2. Installs pre-commit hook for automatic clang-format on staged C++ files\n3. Sets up vtextedit submodule pre-commit hook\n\n## Build Commands\n\n### Release Build\n```bash\nmkdir build && cd build\ncmake ..\ncmake --build . --config Release\n```\n\n### Debug Build\n```bash\nmkdir build && cd build\ncmake .. -DCMAKE_BUILD_TYPE=Debug\ncmake --build . --config Debug\n```\n\n### Windows (PowerShell)\n```powershell\nNew-Item -ItemType Directory -Force -Path build\nSet-Location build\ncmake .. -GNinja\ncmake --build . --config Release\n```\n\n### Clean Build\n```bash\nrm -rf build && mkdir build && cd build && cmake .. && cmake --build .\n```\nNever repair a stale build dir in place.\n\n### Building the Tests\nTests are **not** built by default (`VNOTE_BUILD_TESTS=OFF`), so a plain `cmake ..` produces the\napp only. CI enables them explicitly. To build them locally:\n```bash\ncmake .. -DVNOTE_BUILD_TESTS=ON\ncmake --build .\n```\n\nTesting (two suites, two build dirs): [tests/AGENTS.md](tests/AGENTS.md).\n\n### Pre-push verification (REQUIRED)\n\nBefore pushing parent-repository or submodule changes, agents MUST:\n\n1. Determine the affected build and test targets from **all unpushed changes** relative\n   to the destination branch, not just the latest commit. Trace shared headers/APIs,\n   build configuration and submodule pins to dependent consumers; filenames alone\n   are not sufficient. Broaden coverage when the impact cannot be bounded safely.\n2. Successfully build the affected application/library targets and **all test targets**\n   with tests enabled, using a compatible Qt kit, compiler and build configuration.\n   Include targets excluded from the default build and native-editor or standalone\n   submodule test targets in their own build directories. An app-only build does not\n   verify tests. Follow [tests/AGENTS.md](tests/AGENTS.md) for test build configuration.\n3. Verify the final code/configuration being pushed; rebuild required targets after\n   subsequent changes. Report the scope rationale, build commands, results, skips and\n   platform limits. If all unpushed changes are documentation-only, check the docs and\n   state that no build/test targets are affected; compilation and tests are not required.\n\n**Running tests is not required before pushing.** Successful compilation and linking of\nall test targets is sufficient for the local test verification gate; CI test requirements\nremain unchanged.\n\n**Do not push when required builds fail, time out or cannot run.** Fix the cause and\nverify again; never disable tests or exclude required targets just to obtain a successful\nbuild. If build verification is unavailable, report the exact blocker and obtain an\nexplicit user exception before pushing. A local build is not proof that tests pass or\nthat other operating systems or packaging gates pass; disclose that limitation.\n\n### CI runner debugging (SSH)\n\nWhen a failure needs the hosted runner, follow the key-restricted SSH procedure in\n[CI debugging](.github/AGENTS.md#interactive-ci-debugging). Dispatch a non-publishing\nbranch at the intended revision, preserve first-run evidence, and confirm session closure.\nAn ad-hoc debug pass is not proof that normal CI is fixed.\n\n---\n\n## Submodule Push Discipline (CRITICAL — read before every push)\n\nVNote pins git submodules (`libs/vxcore`, `libs/vtextedit`, `libs/QHotkey`, `libs/qwindowkit`)\nto specific commits. **CI clones submodules from their own remotes**, so a commit that exists\nonly locally fails every CI job at \"Init Submodules\" (`upload-pack: not our ref <sha>`) before\nany build runs.\n\n### Rule: ALWAYS push the submodule FIRST, then the parent repo.\n\n```bash\ncd libs/vxcore\ngit push origin HEAD:main      # or the appropriate branch\ncd ../..\ngit push                       # only now may the gitlink be pushed\n```\nTo push both at once with verification, use `git push --recurse-submodules=on-demand`, or enforce\nit once per clone:\n\n```bash\ngit config push.recurseSubmodules check   # aborts the parent push if a submodule commit is unpushed\n```\n\n### Before pushing, verify no submodule commit is stranded\n\n```bash\n# For each submodule, confirm local HEAD is not ahead of its remote:\ngit submodule foreach 'git status -sb'\n# A line like \"## main...origin/main [ahead 1]\" means an UNPUSHED submodule commit — push it before pushing vnote.\n\n# Confirm the pinned SHA exists on the submodule remote:\ncd libs/vxcore && git branch -r --contains $(git rev-parse HEAD) && cd ../..\n# Empty output = the commit is NOT on any remote branch yet. DO NOT push vnote until it is.\n```\n\nIf CI is already failing with \"not our ref\", the fix is to push the missing submodule commit (do\n**not** roll back the parent pointer if newer parent commits depend on the new submodule API).\n\n### cmark is vendored twice — bump both pins together\n\nThe same cmark fork (`https://github.com/vnotex/cmark.git`) is pinned by two different submodules:\n\n| Parent submodule | Nested cmark path |\n|---|---|\n| `libs/vtextedit` | `libs/cmark` |\n| `libs/vxcore` | `third_party/cmark` |\n\nOnly **one** is ever compiled in: `libs/CMakeLists.txt` adds `vtextedit` first, which defines the\n`cmark` target unconditionally, so vxcore's `if(NOT TARGET cmark)` guard skips its own copy.\nDiverged pins silently build vxcore against an untested cmark, with no error or warning.\n**Bump both submodules to the same cmark commit in a single change.** [`tests/utils/test_cmark_pin_drift.cpp`](tests/utils/test_cmark_pin_drift.cpp) fails the\nbuild when the two recorded pins differ.\n\n---\n\n## Architecture Overview\n\nVNote uses a **clean architecture** with **Model-View-Controller (MVC)** pattern and dependency\ninjection. Models hold data, Views display it, Controllers handle logic, Services own domain\noperations, and every layer receives a `ServiceLocator&` — there are no singletons.\n\n| Layer | Location | Responsibility | Example |\n|-------|----------|----------------|---------|\n| **Model** | `src/models/` | Data representation, Qt Model/View integration | `NotebookNodeModel` exposes node hierarchy via `QAbstractItemModel` |\n| **View** | `src/views/` | Display data, capture user input, emit signals | `NotebookNodeView` renders tree, emits `nodeActivated` signal |\n| **Controller** | `src/controllers/` | Handle actions, orchestrate Model/View, business logic | `NotebookNodeController` handles new/delete/rename operations |\n| **Service** | `src/core/services/` | Domain operations, data access via vxcore | `NotebookCoreService` wraps vxcore C API for notebook CRUD |\n\n### MVC Rules (MUST FOLLOW)\n\n| Rule | Rationale |\n|------|-----------|\n| **Models MUST NOT** contain UI logic | Models are reusable across different views |\n| **Views MUST NOT** modify data directly | Views only display and emit signals |\n| **Controllers MUST NOT** inherit from QWidget | Controllers are testable without GUI |\n| **All layers receive `ServiceLocator&`** | Enables dependency injection and testing |\n| **Use signals/slots between layers** | Loose coupling between M, V, C |\n\nFull diagram, directory tree, design-decision rationale (including the ViewArea2 framework), and\nsource-wide Qt patterns: see [src/AGENTS.md](src/AGENTS.md).\n\n---\n\n## Code Style Guidelines\n\n### Standards\n- **C++17** standard\n- **Qt 6** framework\n- CMake with `CMAKE_AUTOMOC`, `CMAKE_AUTOUIC`, `CMAKE_AUTORCC` enabled\n\n### Formatting\n- 2-space indentation\n- 100 character line limit\n- Pointer alignment right: `int *ptr`, not `int* ptr`\n- Use provided `.clang-format` (auto-applied via pre-commit hook)\n\n### No Hardcoded Colors (enforced)\n\n**Never write a literal color into a `setStyleSheet()` call.** VNote ships 12\nthemes, 6 of them dark; a hardcoded `#RRGGBB`, `rgb()/rgba()` literal, or CSS\ncolor name is correct only in whichever theme its author was running, and it\ncannot follow a runtime theme switch.\n\n`tests/utils/test_hardcoded_color_drift.cpp` is a grep gate over `src/` that\nfails the build on any **stylesheet string** literal containing both a CSS color\nproperty and a literal color value (colors used as *data*, and `QColor` painted\nin a `paintEvent`, are out of scope).\n\nUse `InlineBanner`, the `SeverityText` / `MutedText` dynamic properties, a rule\nin each theme's `interface.qss`, or `ThemeService::paletteColor()`. Do **not**\nuse `setEnabled(false)` to mute text. See\n[src/widgets/AGENTS.md § No Hardcoded Colors in C++](src/widgets/AGENTS.md#no-hardcoded-colors-in-c)\nfor the decision table and the escape hatch.\n\n### Tooltip Copy\n\nTooltip text MUST NOT end with sentence-ending punctuation. Keep punctuation needed between\nsentences or clauses; write parenthetical qualifiers such as `(restart required)` in lowercase\nwithout terminal punctuation.\n\n### Naming Conventions\n\n| Element | Convention | Example |\n|---------|------------|---------|\n| Classes | CamelCase | `ConfigMgr`, `MainWindow` |\n| Methods | camelCase | `getInst()`, `initLoad()` |\n| Parameters | `p_` prefix | `p_parent`, `p_config` |\n| Members | `m_` prefix | `m_themeMgr`, `m_config` |\n| Constants | `c_` prefix | `c_orgName`, `c_appName` |\n| Getters | `get` prefix | `getThemeMgr()`, `getName()` |\n\n### Include Order\n```cpp\n#include \"ownheader.h\"      // Own header first\n\n#include <QDateTime>        // Qt includes\n#include <QObject>\n\n#include \"localheader.h\"    // Local includes\n#include <core/configmgr.h>\n#include <utils/utils.h>\n\nusing namespace vnotex;     // Namespace declaration in .cpp\n```\n\n### Header Guards\n```cpp\n#ifndef CLASSNAME_H\n#define CLASSNAME_H\n// ...\n#endif // CLASSNAME_H\n```\n\n### Namespaces\n\nVNote uses a single `vnotex` namespace. Services that wrap the vxcore C library use the `CoreService` suffix to distinguish them from higher-level wrapper services:\n\n| Class Pattern | Purpose | Examples |\n|---------------|---------|----------|\n| `XXXCoreService` | Low-level services that wrap the vxcore C library (hold `VxCoreContextHandle`) | `ConfigCoreService`, `NotebookCoreService`, `BufferCoreService`, `SearchCoreService`, `FileTypeCoreService` |\n| Other classes | Everything else: UI, controllers, models, hook-aware wrapper services | `BufferService` (hook wrapper), `HookManager`, `TemplateService`, `ConfigMgr2`, controllers, widgets |\n\n**Rules:**\n- `using namespace vnotex;` in `.cpp` files only, never in headers\n- Forward declarations preferred in headers\n\n### Signal/Slot Connections\n```cpp\n// Preferred: typed signal/slot syntax\nconnect(m_taskMgr, &TaskMgr::taskOutputRequested,\n        this, &VNoteX::showOutputRequested);\n\n// With overloaded methods\nconnect(this, &VNoteX::openNodeRequested, m_bufferMgr,\n        QOverload<Node *, const QSharedPointer<FileOpenParameters> &>::of(&BufferMgr::open));\n```\n\nMemory management, queued-connection metatype registration, and the rest of\nthe source-wide patterns: [src/AGENTS.md § Source-Wide Qt Patterns](src/AGENTS.md#source-wide-qt-patterns).\nNoncopyable, `VNOTEX_DEPRECATED` and exception handling: [src/core/AGENTS.md](src/core/AGENTS.md#core-c-facilities).\n\n---\n\n## Sync State Model\n\nNotebook sync has 8 reachable states (S0-S7), defined by the tuple of on-disk JSON sync fields,\ncredential presence in the OS keychain (Git PAT or WebDAV login), and runtime registration in vxcore's `states_` map. **S5 is the\nonly \"ready\" state**; S1-S4 and S6 are partial/inconsistent, S0 is cleanly disabled, S7 is\nin-flight. Every controller, widget, and service that touches sync must reason in these terms.\n\nFull predicate table, recovery paths, reconcile semantics, disable cleanup, the S6 startup sweep,\nand the Qt-side scheduling shape:\n[src/core/services/AGENTS.md § Sync State Model](src/core/services/AGENTS.md#sync-state-model).\nvxcore-side threading contract:\n[libs/vxcore/src/sync/AGENTS.md](libs/vxcore/src/sync/AGENTS.md).\n---\n\n## Save Path Threading Contract\n\nBuffer saves run on a worker via `BufferSaveQueue`; save and git-stage/commit work on the SAME\nnotebook are serialized by the per-notebook `NotebookIoGate` async mutex. WebDAV additionally installs incoming changes under\nthe gate after a GUI-owned buffer reservation and save drain; metadata refresh remains GUI-thread-only.\n\n> **Forbidden Patterns (post-T7):**\n> - Calling `vxcore_buffer_save` directly from the UI thread. Use [`BufferSaveQueue::enqueue`](src/core/services/buffersavequeue.h) instead.\n> - Touching a notebook's working tree (save, stage, commit, checkout) without holding `NotebookIoGate::ScopedLock(notebookId)`.\n\nFull rationale and the two-phase sync gate:\n[src/core/services/AGENTS.md § Save Path Threading Contract](src/core/services/AGENTS.md#save-path-threading-contract).\n\n---\n\n## Cross-Notebook Node Transfer\n\nCross-notebook copy and move operations must go through\n[`NodeTransferService`](src/core/services/nodetransferservice.h). VNote owns editor/comment\ndurability, sync exclusion, IO-gate ordering, hooks, clipboard policy, progress, and UI feedback;\nvxcore owns snapshotting, identity regeneration, storage transactions, recovery, and mutation facts.\nControllers and widgets must not call the vxcore transfer API directly. Full orchestration contract:\n[src/core/services/AGENTS.md § Cross-Notebook Node Transfer](src/core/services/AGENTS.md#cross-notebook-node-transfer).\n\n---\n\n## Search Threading Contract\n\nContent search in vxcore owns NO thread pool: it enqueues one work item per file-chunk onto the\n`\"vxcore.search\"` work queue, and the CALLER owns the drain policy (VNote's `SearchService` runs\nthe drain pool; the initiating thread help-drains, which is the single-threaded correctness\nfloor).\n\nFull contract: [src/core/services/AGENTS.md § Search Threading Contract](src/core/services/AGENTS.md#search-threading-contract).\n\n---\n\n## Update Check\n\nOrdinary startup and manual checks fetch release metadata and offer the **release page**.\n`UpdateService` and `UpdateInfo` remain check-only: no asset selection, download, verification\nor installation is added to them.\n\n> **An ordinary check never modifies the install directory or downloads release artifacts.**\n> It creates no lease, staging tree or journal, and extracts or executes nothing. Its only\n> writes are the `lastUpdateCheckTime` / `skippedUpdateVersion` config values. Startup must\n> remain usable from read-only installations (`/usr/bin`, Program Files, a read-only DMG;\n> issue #2728), without probing install-directory writability.\n\nThe sole installation exception is an **explicit Windows “Update Now” notification action**\nlaunching the deployed external PowerShell script. Keep “Check Release” first and available;\nthe manual-check dialog is unchanged and has no automatic-install entry point. The action\nmust disclose downloading, closing and reopening VNote; checks never activate it themselves.\n\nThe external updater must authenticate the offered version's signed full-package manifest\nwith the bundled minisign verifier before using it, then verify the archive and payload.\nIt consumes no deltas, never falls back to another source or unsigned artifacts, and requests\nshutdown only after preparation succeeds. A launch-scoped, PID/token-authenticated pipe and\nan explicit accepted close authorize installation; cancellation or pipe EOF does not. Wait\nfor the original process to exit, including save/sync drains, without force-killing it.\n\nPreserve portable `config` and all unowned files by cloning the stopped installation and\noverlaying verified payload files, then swapping sibling directories. Reject path/reparse\nhazards and incompatible collisions; retain a complete recovery backup and restore it if\nthe second rename fails. Protected installations require manual-update guidance, **never\nelevation, ACL changes or dependency installation**. Reopen with the helper's inherited token;\nreport installed-but-not-reopened separately. Two renames are not power-loss atomic.\n\nRepo-wide forbidden patterns (also constrain CI, packaging, controllers and widgets):\n\n- **Never** download, extract, execute or install a release artifact during an ordinary check.\n- **Never** write outside the configuration directory as part of an update check.\n- **Never** read `assets[]` in `UpdateService`; only the explicit external updater selects assets.\n- **Never** give `UpdateService` a `ConfigMgr2` dependency — add policy to the controller.\n- **Never** restore the C++ installer, delta application or a general-purpose quit CLI/protocol.\n\nThe Windows release publishes a signed full-package manifest and may also publish\nunused delta ZIPs. Signing and external-verifier contract: [docs/update-signing.md](docs/update-signing.md).\nController launch/shutdown ownership: [src/controllers/AGENTS.md § Update Check](src/controllers/AGENTS.md#update-check).\nEndpoints, source-scoped redirects and check threading:\n[src/core/services/AGENTS.md § Update Check](src/core/services/AGENTS.md#update-check).\n\n---\n\n## Logging\n\nUse Qt logging macros:\n```cpp\nqDebug() << \"Debug message\";\nqInfo() << \"Info message\";\nqWarning() << \"Warning message\";\nqCritical() << \"Critical error\";\n```\n\n## Code Formatting\n\nThe pre-commit hook automatically formats staged C++ files using clang-format.\n\n**Manual formatting:**\n```bash\nclang-format -i src/core/myfile.cpp\n```\n\n**Excluded from formatting:** `libs/` directory (third-party code)\n\n### Format before staging and committing\n\nAgents MUST format intended C++ changes with the repository configuration and the same formatter\nas the pre-commit hook **before staging**. Review the resulting diff and preserve unrelated edits.\nPrefer explicit `git add -- <paths>` followed by ordinary `git commit` when the index contains only\nintended changes. If other work must remain staged, a path-limited commit (`git commit --only` or\n`git commit <paths>`) must not rely on the hook for further formatting: verify the formatter would\nmake no additional changes first.\n\nPath-limited commits use a temporary index. The hook's `clang-format -i` and `git add` can update\nthe working file and committed snapshot while leaving the normal index with older formatting.\nAfter such a commit, compare HEAD, index and working tree for the committed paths; do not blindly\nclassify `MM` leftovers as user edits and keep restoring them.\n\nClean up only residue proven to come from the agent's workflow: when `git diff HEAD -- <path>` is\nempty and staged/unstaged formatting hunks are exact inverses, `git restore --staged -- <path>`\nresynchronizes the index without changing the working file. An empty HEAD diff alone does not\njustify clearing intentional user staging. Never use blanket reset/restore or stash for cleanup.\n\n---\n\n## Shared JSON Keys (SSOT)\n\nCross-boundary JSON keys (vxcore↔Qt) live in `<vxcore/notebook_json_keys.h>`.\nSee [`libs/vxcore/AGENTS.md` § JSON Conventions](libs/vxcore/AGENTS.md#json-conventions)\nfor the SSOT contract and the `test_json_key_drift` regression gate.\n\n---\n\n## Module Documentation Index\n\nDetailed knowledge for each module lives in its own AGENTS.md.\n\n**Where to write new documentation:** default to the child `AGENTS.md` that owns the code (create\none for the directory if it does not exist yet). The root doc is injected into *every* agent turn,\nso anything added here costs context on turns that will never need it. Add to root only when the\nknowledge is genuinely repo-wide — i.e. it constrains callers who will never load the owning\nmodule's doc (as the MVC rules and the update-install invariant do), or it is a build/setup/style\nrule that applies everywhere. Even then, keep root to a short normative summary plus a link, and\nput the full detail in the module doc.\n\n| Module | File | Read this when |\n|--------|------|----------------|\n| Source overview | [src/AGENTS.md](src/AGENTS.md) | You need the architecture diagram, directory tree, design-decision rationale, or a source-wide Qt pattern (memory, queued metatypes) |\n| Core & Services | [src/core/AGENTS.md](src/core/AGENTS.md) | ServiceLocator, DI, Buffer2, hooks, config, themes, adding a service |\n| Services (deep) | [src/core/services/AGENTS.md](src/core/services/AGENTS.md) | Sync state model, save/search threading, update check, notifications |\n| Controllers | [src/controllers/AGENTS.md](src/controllers/AGENTS.md) | Adding or changing a controller; MVC rules for controllers |\n| Models | [src/models/AGENTS.md](src/models/AGENTS.md) | Qt Model/View data representations |\n| Views | [src/views/AGENTS.md](src/views/AGENTS.md) | View conventions, delegate patterns |\n| Widgets | [src/widgets/AGENTS.md](src/widgets/AGENTS.md) | Widget conventions, ViewArea2 framework, styling, construction pattern |\n| GUI Services | [src/gui/AGENTS.md](src/gui/AGENTS.md) | Theme, ViewWindowFactory, GUI utilities |\n| Utilities | [src/utils/AGENTS.md](src/utils/AGENTS.md) | PathUtils, HtmlUtils, FileUtils2 reference |\n| pdf.js assets | [src/data/extra/web/pdf.js/AGENTS.md](src/data/extra/web/pdf.js/AGENTS.md) | Touching the vendored PDF viewer or its Qt 6.9+ WebEngine requirement |\n| Testing | [tests/AGENTS.md](tests/AGENTS.md) | Agent GUI smoke testing with `--no-single-instance`, test suites, test mode, fixtures, coverage |\n| CI & Packaging | [.github/AGENTS.md](.github/AGENTS.md) | Workflows, SSH runner debugging, `src/Packaging.cmake`, Qt 6 release packages |\n| vxcore (submodule) | [libs/vxcore/AGENTS.md](libs/vxcore/AGENTS.md) | C library: notebook/config/search backend |\n| vxcore Sync | [libs/vxcore/src/sync/AGENTS.md](libs/vxcore/src/sync/AGENTS.md) | Pluggable sync backend interface (ISyncBackend, SyncManager) |\n| vtextedit (submodule) | [libs/vtextedit/AGENTS.md](libs/vtextedit/AGENTS.md) | Qt editor widget library |\n","isInternal":false,"tokens":5257,"sizeBytes":22324},{"name":"AGENTS.md","path":"src/AGENTS.md","rawUrl":"https://raw.githubusercontent.com/vnotex/vnote/HEAD/src/AGENTS.md","title":"src - AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"# src/ — Architecture & Source-Wide Patterns\n\nThis file is inherited by every module under `src/`. It holds the full VNote architecture\n(MVC layering, design-decision rationale, directory tree) and the Qt/C++ patterns that apply\nto all source code regardless of module.\n\nThe normative MVC rules table and the repo-wide build/style rules live in the root\n[AGENTS.md](../AGENTS.md).\n\nPackaging and CI rules — including `src/Packaging.cmake` and Qt 6 deployment — live in\n[.github/AGENTS.md](../.github/AGENTS.md). **An edit to `src/Packaging.cmake` does not\nauto-load that file; open it explicitly.**\n\n---\n\n## Architecture\n\nVNote uses a **clean architecture** with **Model-View-Controller (MVC)** pattern and dependency injection for testability and future plugin support.\n\n### Core Principles\n\n1. **MVC Separation** — Models hold data, Views display it, Controllers handle logic\n2. **Dependency Injection** — No singletons; dependencies passed via ServiceLocator\n3. **Service Layer** — Business logic encapsulated in services, accessed via ServiceLocator\n4. **Hook System** — WordPress-style extensibility for plugins\n\n### MVC Architecture (CRITICAL)\n\nVNote strictly follows the MVC pattern. **All new code MUST adhere to this structure.**\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│                     Controllers                             │\n│  (src/controllers/ - Handle user actions, business logic)   │\n│                                                             │\n│  NotebookNodeController, NewNoteController, etc.            │\n└─────────────────────────────────────────────────────────────┘\n        │                                       │\n        │ Manipulates                          │ Emits signals to\n        ▼                                       ▼\n┌───────────────────────┐       ┌──────────────────────────────┐\n│        Models         │       │           Views              │\n│  (src/models/)        │◄──────│  (src/views/)                │\n│                       │       │                              │\n│  NotebookNodeModel    │ Data  │  NotebookNodeView            │\n│  (QAbstractItemModel) │ flows │  (QTreeView subclass)        │\n└───────────────────────┘       └──────────────────────────────┘\n        │                                       │\n        │ Fetches data from                    │ Receives from\n        ▼                                       ▼\n┌─────────────────────────────────────────────────────────────┐\n│                     ServiceLocator                          │\n│  (DI container - NOT a singleton, passed by reference)      │\n│                                                             │\n│  ┌─────────────────┐  ┌─────────────────────┐  ┌───────────────────┐  │\n│  │ConfigCoreService│  │ NotebookCoreService │  │SearchCoreService  │  │\n│  └─────────────────┘  └─────────────────────┘  └───────────────────┘  │\n└─────────────────────────────────────────────────────────────┘\n                              │\n                              ▼\n┌─────────────────────────────────────────────────────────────┐\n│                         vxcore                              │\n│  (C library: notebook/config/search backend in libs/vxcore) │\n└─────────────────────────────────────────────────────────────┘\n```\n\n### MVC Responsibilities\n\n| Layer | Location | Responsibility | Example |\n|-------|----------|----------------|---------|\n| **Model** | `src/models/` | Data representation, Qt Model/View integration | `NotebookNodeModel` exposes node hierarchy via `QAbstractItemModel` |\n| **View** | `src/views/` | Display data, capture user input, emit signals | `NotebookNodeView` renders tree, emits `nodeActivated` signal |\n| **Controller** | `src/controllers/` | Handle actions, orchestrate Model/View, business logic | `NotebookNodeController` handles new/delete/rename operations |\n| **Service** | `src/core/services/` | Domain operations, data access via vxcore | `NotebookCoreService` wraps vxcore C API for notebook CRUD |\n\n### MVC Example: Notebook Node Operations\n\n```cpp\n// Controller handles user action\nvoid NotebookNodeController::newNote(const NodeIdentifier &p_parentId) {\n  // 1. Emit signal to View to show dialog\n  emit newNoteRequested(p_parentId);\n}\n\n// View shows dialog, then calls back to Controller\nvoid NotebookExplorer2::onNewNoteResult(const NodeIdentifier &p_parentId,\n                                        const NodeIdentifier &p_newNodeId) {\n  m_controller->handleNewNoteResult(p_parentId, p_newNodeId);\n}\n\n// Controller updates Model\nvoid NotebookNodeController::handleNewNoteResult(const NodeIdentifier &p_parentId,\n                                                  const NodeIdentifier &p_newNodeId) {\n  // Model reloads from NotebookCoreService\n  m_model->reloadNode(p_parentId);\n}\n```\n\n### MVC Rules (MUST FOLLOW)\n\nThe normative rules table lives at\n[root AGENTS.md § MVC Rules (MUST FOLLOW)](../AGENTS.md#mvc-rules-must-follow) — that is the\nsingle source of truth, and the anchor `controllers/`, `models/`, `views/` and `widgets/` all\nlink to. Do not copy the table here.\n\n### Key Design Decisions\n\n| Decision | Rationale |\n|----------|-----------|\n| ServiceLocator is NOT a singleton | Enables testing with mock services; explicit dependencies |\n| Services wrap vxcore C API | Qt-friendly interface; encapsulates C interop |\n| Controllers are QObject, not QWidget | Testable business logic without GUI dependencies |\n| Widgets receive `ServiceLocator&` | Constructor injection; no global state |\n| Some files carry a `2` suffix (`MainWindow2`, `Buffer2`, …) | Historical artifact of the now-complete migration off the legacy singleton architecture. The pre-migration counterparts have been removed; the suffix is retained on those existing classes only to avoid a churny rename. The migration is finished, so **new code uses the plain, unsuffixed name unless it genuinely conflicts with an existing type** (e.g. a brand-new `EncodingButton` gets no suffix). Never introduce a `3` suffix. |\n| `Buffer2` is a lightweight copyable handle (like `QModelIndex`) | Returned by `BufferService::openBuffer()`, delegates to `BufferCoreService`; NOT a `QObject`, not heap-allocated |\n| `BufferService` privately inherits `BufferCoreService` | Hook-aware wrapper that fires `vnote.file.*` hooks around core operations |\n| `NodeIdentifier` is a standalone value type | Identifies a node by `notebookId` + `relativePath`; used by `Buffer2`, controllers, and views |\n| `ConfigMgr2` is the ONLY way to access typed config | Owns `MainConfig`/`SessionConfig` with properly merged defaults. NEVER construct a throwaway `MainConfig` from raw JSON — use `m_services.get<ConfigMgr2>()` instead. See `src/core/AGENTS.md` for details. |\n| `ConfigMgr2::getFileFromConfigFolder()` for path resolution | Resolves relative config paths (e.g., `\"web/markdown-viewer-template.html\"`) against the app data directory. Do NOT use `ConfigCoreService::getDataPath()` + manual `QDir::filePath()`. |\n| `PathExists()`/`IsDirectory()`/`IsRegularFile()` wrappers | NEVER pass raw `std::string` to `std::filesystem` — use these wrappers or `PathFromUtf8()` for non-ASCII path safety on Windows |\n\n### Key Design Decisions (ViewArea2 Framework)\n\n| Decision | Rationale |\n|----------|-----------|\n| `ViewAreaController` is the orchestrator | Handles open/close/split/move logic, fires hooks, uses WorkspaceCoreService |\n| `ViewArea2` is a pure view | Owns QSplitter tree + ViewSplit2 instances, no business logic |\n| `ViewSplit2` maps 1:1 to vxcore workspace | Each tab widget pane corresponds to one `WorkspaceCoreService` workspace |\n| `ViewWindow2` receives an initial `Buffer2` in its constructor | Opt-in same-type windows can rebind through the guarded replacement lifecycle while retaining their ID/resources; queued work must check buffer generation. See [tab reuse](widgets/AGENTS.md#note-tab-reuse). |\n| `ViewWindowFactory` maps file types to creators | Registry pattern; plugins register creators for new file types |\n| Splitter orientation follows Vim convention | Left/Right split → `Qt::Horizontal`, Up/Down split → `Qt::Vertical` |\n| Session layout stored as JSON in `SessionConfig` | Recursive splitter tree + workspace IDs for full layout persistence |\n| Concrete ViewWindows live alongside the framework | `MarkdownViewWindow2`, `TextViewWindow2`, `PdfViewWindow2`, `MindMapViewWindow2`, and `WidgetViewWindow2` are registered with `ViewWindowFactory` per file type |\n\n### Directory Structure\n\n```\nsrc/\n├── main.cpp                # Entry point with DI wiring\n├── core/\n│   ├── servicelocator.h    # DI container\n│   ├── nodeidentifier.h    # Lightweight node ID (notebookId + relativePath)\n│   ├── services/           # Service layer (wraps vxcore)\n│   │   ├── configcoreservice.h/.cpp\n│   │   ├── notebookcoreservice.h/.cpp\n│   │   ├── searchcoreservice.h/.cpp\n│   │   ├── filetypecoreservice.h/.cpp\n│   │   ├── buffercoreservice.h/.cpp\n│   │   ├── bufferservice.h/.cpp    # Hook-aware wrapper, returns Buffer2\n│   │   ├── buffer2.h/.cpp          # Lightweight buffer handle (like QModelIndex)\n│   │   ├── templateservice.h/.cpp\n│   │   ├── historyservice.h/.cpp   # Aggregate per-notebook history across notebooks\n│   │   ├── workspacecoreservice.h/.cpp  # Workspace operations (split pane ↔ vxcore workspace)\n│   │   └── hookmanager.h/.cpp\n│   ├── hookcontext.h       # Hook callback context\n│   ├── hooknames.h         # Hook name constants\n│   ├── configmgr2.h/.cpp   # High-level config manager using DI\n│   └── iconfigmgr.h        # Interface for config managers\n├── gui/                    # GUI-aware services and utilities\n│   ├── services/\n│   │   ├── themeservice.h/.cpp         # GUI-aware theme management service\n│   │   └── viewwindowfactory.h/.cpp    # Registry mapping file types to ViewWindow2 creators\n│   └── utils/\n│       ├── widgetutils.h/.cpp          # Widget utility helpers\n│       ├── themeutils.h/.cpp           # Theme utility helpers\n│       ├── imageutils.h/.cpp           # Image utility helpers\n│       └── guiutils.h/.cpp             # General GUI utilities\n├── models/                 # Qt Model/View models\n│   ├── notebooknodemodel.h/.cpp       # QAbstractItemModel for node hierarchy\n│   └── notebooknodeproxymodel.h/.cpp  # Proxy model for sorting/filtering\n├── views/                  # Qt views and delegates\n│   ├── notebooknodeview.h/.cpp        # QTreeView for nodes\n│   ├── notebooknodedelegate.h/.cpp    # Item delegate for node rendering\n│   ├── combinednodeexplorer.h/.cpp    # Composite MVC wiring widget\n│   └── filenodedelegate.h/.cpp        # Item delegate for file list\n├── controllers/            # Controllers (business logic mediators)\n│   ├── notebooknodecontroller.h/.cpp  # Node operations controller\n│   ├── newnotecontroller.h/.cpp       # New note dialog controller\n│   ├── newfoldercontroller.h/.cpp     # New folder dialog controller\n│   ├── newnotebookcontroller.h/.cpp   # New notebook dialog controller\n│   ├── opennotebookcontroller.h/.cpp  # Open notebook flow controller\n│   ├── managenotebookscontroller.h/.cpp\n│   ├── importfoldercontroller.h/.cpp\n│   ├── recyclebincontroller.h/.cpp\n│   └── viewareacontroller.h/.cpp      # View area orchestrator (open/close/split/move)\n├── widgets/                # UI widgets (views receiving ServiceLocator&)\n│   ├── mainwindow2.h/.cpp  # Main window shell\n│   ├── notebookexplorer2.h/.cpp\n│   ├── notebookselector2.h/.cpp\n│   ├── toolbarhelper2.h/.cpp\n│   ├── viewwindow2.h/.cpp  # Abstract base for file viewer windows\n│   ├── markdownviewwindow2.h/.cpp  # Markdown editor/preview window\n│   ├── textviewwindow2.h/.cpp      # Plain text editor window\n│   ├── pdfviewwindow2.h/.cpp       # PDF viewer window\n│   ├── mindmapviewwindow2.h/.cpp   # Mind map viewer window\n│   ├── widgetviewwindow2.h/.cpp    # Generic widget-hosting window\n│   ├── viewsplit2.h/.cpp   # QTabWidget-based split pane (one vxcore workspace)\n│   ├── viewarea2.h/.cpp    # Splitter tree view (manages ViewSplit2 layout)\n│   └── dialogs/            # Dialog widgets\n│       ├── newnotedialog2.h/.cpp\n│       ├── newfolderdialog2.h/.cpp\n│       ├── newnotebookdialog2.h/.cpp\n│       ├── managenotebooksdialog2.h/.cpp\n│       └── importfolderdialog2.h/.cpp\n├── net/\n│   └── networkutils.h/.cpp # core_net: Qt Core/Network-only HTTP helpers\n│                           # (vnotex::NetworkUtils / NetworkReply / NetworkAccess)\n├── utils/\n│   └── fileutils2.h/.cpp   # File utilities\n└── ...\n```\n\n---\n\n## Source-Wide Qt Patterns\n\n### Memory Management\n```cpp\n// Use Qt smart pointers\nQScopedPointer<MainConfig> m_config;\nQSharedPointer<Task> task;\n\n// QObject parent-child for automatic cleanup\nm_themeMgr = new ThemeMgr(this);  // 'this' takes ownership\n```\n\n### Queued-Connection Metatypes on Qt 6\n\nQueued calls need copyable argument types known to Qt's metatype system. Keep\n`Q_DECLARE_METATYPE` and the existing typed `qRegisterMetaType<T>()` registrations for\ncustom values crossing threads. Qt 6's moc-generated signal metadata supplies parameter\ntypes directly; an unqualified signal spelling alone does **not** require an extra\n`qRegisterMetaType<T>(\"T\")` alias.\n\nString-based APIs still need their exact runtime type names. In particular, the existing\n`QMetaObject::invokeMethod(worker, \"method\", Qt::QueuedConnection, Q_ARG(T, value))` form\nmust resolve the spelling stringified by `Q_ARG`. If `Q_DECLARE_METATYPE(vnotex::T)`\nregisters the qualified name but `Q_ARG` spells `T`, retain the unqualified alias as well.\n\n`ImageHostService` registers `\"ImageHostWorkItem\"` for its `doUpload`, `doRemove` and\n`doTestConfig` worker dispatches. Keep that registration; real worker delivery is\ncovered by `tests/core/test_imagehostasync.cpp`.\nThe typed `SearchWorker` result/error and `ImageHostWorker` completion connections need\nno extra unqualified aliases. Real queued search delivery is covered by\n`testSimpleSearchUnicodeReplacement` and `testSearchContentStreamingBatchUnion` in\n`tests/core/test_searchservice.cpp`.\n\nDo not add aliases to every `Q_DECLARE_METATYPE` indiscriminately: inspect the actual\nstring-based callers, if any, before adding or removing a named registration.\n\n---\n\n## Related Modules\n\n| Module | File | Read this when |\n|--------|------|----------------|\n| Core & Services | [core/AGENTS.md](core/AGENTS.md) | ServiceLocator, DI, Buffer2, hooks, config, adding a service |\n| Services (deep) | [core/services/AGENTS.md](core/services/AGENTS.md) | Sync, save/search threading, update check, service internals |\n| Controllers | [controllers/AGENTS.md](controllers/AGENTS.md) | Adding or changing a controller |\n| Models | [models/AGENTS.md](models/AGENTS.md) | Qt Model/View data representations |\n| Views | [views/AGENTS.md](views/AGENTS.md) | View conventions, delegate patterns |\n| Widgets | [widgets/AGENTS.md](widgets/AGENTS.md) | Widget conventions, ViewArea2 framework, styling |\n| GUI Services | [gui/AGENTS.md](gui/AGENTS.md) | Theme, ViewWindowFactory, GUI utilities |\n| Utilities | [utils/AGENTS.md](utils/AGENTS.md) | PathUtils, HtmlUtils, FileUtils2 |\n| CI & Packaging | [../.github/AGENTS.md](../.github/AGENTS.md) | `src/Packaging.cmake`, Qt 6 deployment |\n","isInternal":false,"tokens":3664,"sizeBytes":17148},{"name":"AGENTS.md","path":"tests/AGENTS.md","rawUrl":"https://raw.githubusercontent.com/vnotex/vnote/HEAD/tests/AGENTS.md","title":"tests - AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"# Agent Guidelines for tests\n\nTest infrastructure for VNote using the QtTest framework. Parent VNote Qt tests live under\n`tests/` and are built via CMake's `add_qt_test()` helper; vxcore has a **separate** suite under\n`libs/vxcore/tests/` with its own build dir and `add_vxcore_test()` helper. Tests exercise\nservices, models, controllers, and utilities against the vxcore C backend in isolated test mode.\n\n## Two test locations — pick the right one\n\nVNote has tests in **two separate locations** with different registration helpers and build\nconfigurations. Pick the right one or your test will silently never run.\n\n### Parent VNote tests (`tests/`)\n\nBuilt with the parent project into `build-debug/tests/<category>/` **when `-DVNOTE_BUILD_TESTS=ON`\nis set** (it defaults to OFF; CI turns it ON). Registered via\nthe `add_qt_test()` helper (details in [Adding New Tests](#adding-new-tests) below). Categories:\n`controllers/`, `core/`, `gui/`, `integration/`, `models/`, `utils/`, `widgets/`.\n\n`VNOTE_BUILD_TESTS` also controls the bundled vtextedit test targets. VNote sets\n`VTEXTEDIT_BUILD_TESTS` from that flag; standalone vtextedit keeps its own default ON.\n\nConfigure the test-enabled build dir from repo root (also re-run this if `tests/CMakeLists.txt`\nitself changed): `cmake -B build-debug -DVNOTE_BUILD_TESTS=ON`.\n\n**Build + run:**\n```powershell\ncmake --build build-debug --config Debug --target test_yourthing\nctest --test-dir build-debug -C Debug -R \"^test_yourthing$\" --output-on-failure\n```\n\n### vxcore submodule tests (`libs/vxcore/tests/`)\n\n**Separate build dir required** — the parent `build-debug/` does NOT compile vxcore tests. Use a\ndedicated build dir configured with `-DVXCORE_BUILD_TESTS=ON`. Each test is a standalone\nexecutable (file basename = test target name) registered via `add_vxcore_test()`.\n\n**Add a new test:**\n1. Create `libs/vxcore/tests/test_yourthing.cpp` with its own `main()`. Subtests are functions that return `int` (0 = pass); call them via `RUN_TEST(...)` from `test_utils.h`. Always start `main()` with `vxcore_set_test_mode(1)` so the test uses `%TEMP%\\vxcore_test*` instead of real AppData.\n2. Register in `libs/vxcore/tests/CMakeLists.txt`:\n   ```cmake\n   add_vxcore_test(test_yourthing)\n   ```\n   The helper auto-links `vxcore` and adds `${CMAKE_SOURCE_DIR}/src` + `third_party` include dirs.\n3. Test executables CANNOT call vxcore-internal symbols that lack `VXCORE_API` — they live outside the DLL. If you need an internal helper (e.g., `GetCurrentTimestampMillis()`), re-derive it locally in the test file rather than exporting it.\n\n**Configure (once per machine, or after CMake version upgrade):**\n```powershell\ncmake -S libs/vxcore -B libs/vxcore/build_test -G \"Visual Studio 17 2022\" -A x64 -DVXCORE_BUILD_TESTS=ON\n```\n\n**Build + run:**\n```powershell\ncmake --build libs/vxcore/build_test --config Debug --target test_yourthing\nctest --test-dir libs/vxcore/build_test -C Debug -R \"^test_yourthing$\" --output-on-failure\n```\n\n### Universal rules\n\n- **Anchor the ctest regex** with `^...$` to avoid false matches (e.g., `-R \"^test_sync$\"` won't sweep in `test_session_persistence`).\n- After a CMake version upgrade, stale build dirs fail to reconfigure with errors like `CMakeSystem.cmake.in does not exist`. Delete the offending build dir and re-run the configure command from scratch — do NOT try to repair in place.\n- After modifying a touched module, run the FULL test target for that module (not just your new subtest) to catch regressions; vxcore tests print each subtest name to stdout so you can verify the new one ran.\n\nChild-process readiness is a byte-stream protocol: accumulate stdout until the complete\nmarker arrives under one deadline. IPC slow-start fixtures must use blocking reads rather\nthan pumping the primary event loop while waiting for that marker.\n## Test Structure\n\n```\ntests/\n├── CMakeLists.txt          # Root test config with add_qt_test() helper\n├── helpers/\n│   ├── CMakeLists.txt\n│   ├── test_helper.h       # Common includes\n│   └── temp_dir_fixture.h  # QTemporaryDir wrapper (+ copyFrom() for fixtures)\n├── data/                   # On-disk test fixtures (see \"Test Data Fixtures\" below)\n│   └── vnote3_notebooks/\n│       └── database_notebook/  # Real VNote3 notebook with 17 files + subfolder\n├── core/\n│   ├── CMakeLists.txt\n│   ├── test_error.cpp\n│   ├── test_exception.cpp\n│   ├── test_configservice.cpp\n│   ├── test_notebookservice.cpp\n│   ├── test_bufferservice.cpp   # BufferCoreService tests\n│   ├── test_buffer.cpp          # Buffer2 + BufferService integration tests (29 cases)\n│   └── test_vnote3migrationservice.cpp  # VNote3 migration tests (46 cases)\n└── utils/\n    ├── CMakeLists.txt\n    ├── test_pathutils.cpp\n    └── test_htmlutils.cpp\n```\n\n## CRITICAL: Test Mode for vxcore\n\n**Always enable test mode BEFORE creating vxcore context in tests:**\n\n```cpp\nvoid TestMyService::initTestCase() {\n  // CRITICAL: Must call BEFORE vxcore_context_create()\n  // Prevents tests from corrupting real user data\n  vxcore_set_test_mode(1);\n\n  m_ctx = vxcore_context_create();\n  // ...\n}\n```\n\n**Why this matters:**\n- Without test mode, vxcore uses real `AppData/Local` paths\n- Tests will corrupt actual user configuration\n- Test mode redirects to isolated temp directories\n\nEncryption fixtures created by `prepare_notebook` / `commit_notebook` are self-contained v2\nnotes. Reopen them with `vxcore_encryption_unlock_note`, using the notebook-relative path\ncaptured before closing the buffer; `unlock_notebook` is only for explicit legacy-vault tests.\n\n## Writing Service Tests\n\n```cpp\n// tests/core/test_myservice.cpp\n#include <QtTest>\n\n#include <vxcore/vxcore.h>\n\n#include <core/myservice.h>\n\nnamespace tests {\n\nclass TestMyService : public QObject {\n  Q_OBJECT\n\nprivate slots:\n  void initTestCase();\n  void cleanupTestCase();\n  void testBasicOperation();\n\nprivate:\n  vxcore_context *m_ctx = nullptr;\n};\n\nvoid TestMyService::initTestCase() {\n  vxcore_set_test_mode(1);  // CRITICAL!\n  m_ctx = vxcore_context_create();\n}\n\nvoid TestMyService::cleanupTestCase() {\n  vxcore_context_destroy(m_ctx);\n  m_ctx = nullptr;\n}\n\nvoid TestMyService::testBasicOperation() {\n  vnotex::MyService service(m_ctx);\n  QVERIFY(!service.doSomething().isEmpty());\n}\n\n} // namespace tests\n\nQTEST_GUILESS_MAIN(tests::TestMyService)\n#include \"test_myservice.moc\"\n```\n\n## Enable Tests\nEnable parent and bundled editor tests explicitly at configure time; do not edit CMake files:\n```bash\ncmake -S . -B build -DVNOTE_BUILD_TESTS=ON\n```\n\nCMake retains cached choices. An existing ON build stays ON even though the default is OFF;\nconfigure with `-DVNOTE_BUILD_TESTS=OFF` to remove both sets of test targets from that build.\n\n## Build Tests\n```bash\ncmake --build build --config Release --target test_error test_exception test_pathutils test_htmlutils\n```\n\n### Test Libraries\n\n| Library | Purpose | Links |\n|---------|---------|-------|\n| `core_services` | Service layer (ConfigCoreService, NotebookCoreService, etc.) | Qt6::Core, Qt6::Gui, Qt6::Network, Qt6::Concurrent, vxcore, core_net |\n| `core_net` | Qt-Core/Network-only HTTP helpers (`vnotex::NetworkAccess`) | Qt6::Core, Qt6::Network |\n| `core_configs` | Config classes (ConfigMgr2, MainConfig, SessionConfig, etc.) | core_services, VTextEdit |\n\n`core_services` links **neither `Qt::Widgets` nor `VTextEdit`** — see [`../src/core/services/AGENTS.md`](../src/core/services/AGENTS.md#core_services-is-qt-widgets-free-and-vtextedit-free-contract). Practical consequence for tests: a target that needs QtWidgets or vtextedit headers must say so in its own `LINKS`; nothing arrives transitively through `core_services` any more.\n\n## Run Tests\n\n**Windows (Qt DLLs must be in PATH):**\n```powershell\n$env:PATH = \"C:/Qt/6.9.3/msvc2022_64/bin;\" + $env:PATH\n./build/tests/core/test_error.exe\n./build/tests/core/test_configservice.exe\n./build/tests/core/test_notebookservice.exe\n```\n\n**Using CTest (requires Qt in system PATH):**\n```bash\nctest --test-dir build                    # Run all tests\nctest --test-dir build -R test_error      # Run single test (pattern match)\nctest --test-dir build --output-on-failure  # Show output on failure\n```\n\n**macOS keychain tests:** core tests that exercise real QtKeychain jobs use\n`VNOTE_KEYCHAIN_TEST_MAIN` from `test_helper.h`: a native GUI application on macOS,\n`QCoreApplication` elsewhere. On macOS, run these and keychain-using widget tests with\n`QT_QPA_PLATFORM=cocoa`, not `offscreen`: QtKeychain completes on the native main queue,\nwhich the offscreen UNIX dispatcher does not drain. Do not set\n`QT_EVENT_DISPATCHER_CORE_FOUNDATION`: it also replaces worker-thread dispatchers and\ncan stall queued work. Tests also need an unlocked default keychain; macOS CI creates\na disposable one after compilation, immediately before CTest, so the unlock lease covers\nexecution rather than the cold build. It restores the previous default after testing.\n\nFor asynchronous modal-error tests, wait for controller completion with `QSignalSpy::wait`.\nKeep the dismissal timer active until completion and restrict it to the tested dialog's\nmessage box. Do not stop on the first click or infer dialog readiness from\n`QThread::loopLevel()`.\n\n`ConsentDialogDriver` shares the native resource/preview consent fixture. It owns a\nparent-matched `QPointer<QMessageBox>` and an answer timer through dispatch and destruction\nof the completed message box, not just until the context menu or click returns. A Cocoa native alert's QWidget is a hidden\nmirror (`WA_DontShowOnScreen`), not its interactive surface: click the real NSButton's\nYes/No/YesToAll tag on the active native alert, or the parent's attached sheet for WindowModal.\nNever re-show or click that dummy QWidget during native modal startup. The QWidget fallback\nrequires the matching visible active modal and its visible expected button. Cocoa uses\nan `NSTimer` in common/modal/tracking run-loop modes: a native alert nested inside manual\nQt event dispatch can suppress QTimer activation. Invalidate before answering and on scope\nexit; the same original eight-second deadline covers readiness and completion. Native Escape\nuses the alert's key-equivalent path; Close uses `QMessageBox::close()` to end its native\nmodal helper as well. Neither may be replaced with a No-button click.\n\n**Native WebEngine raster tests:** on macOS, `test_mindmapexport`, `test_htmlpdfexporter`,\n`test_markdownwebjobs` and `test_webviewservice` use `cocoa` and the matching Qt kit's\nplatform plugin, not `offscreen`, so hidden views have native graphics contexts.\nKeep deployed-app WebEngine overrides cleared and non-macOS environments unchanged.\n\nNative Markdown render waits observe the adapter's completed document generation and\nactual DOM, not the independent web-job handshake. JavaScript observations pump events;\nuse one `QTest::qWaitFor` and recheck the generation after observation, rather than a\n`QTRY` predicate that can become false again during its post-success evaluation.\nIf the next assertion requires native currentness, use `waitForCurrentRender`, not the\nDOM-only `waitForRender`; protected/public handoffs retain all token and stale-work checks.\n\nTypst context-menu fixtures select Paste by Qt's `edit-paste` action ID and retire\nresources after action admission, not from a timer that can fire during Cocoa's menu\nflash. Empty clipboard MIME data is nullable. Native drag fixtures must release input\nwithout depending on GUI timers: Windows OLE and Cocoa tracking loops can starve them.\nUse joined native-input workers, restoring the cursor and preventing late input from\nreaching the next case; Cocoa retains app-local NSEvents rather than global HID input.\nWindows drag motion uses non-coalesced `SendInput` records in normalized virtual-desktop\ncoordinates. After selecting text, acquire foreground, repaint, deliver posted `LayoutRequest`\nevents, repaint the resolved gutter layout and complete `DwmFlush` before measuring positions.\nAfter source-pointer receipt, recheck mapped start/end and `widgetAt` before pressing: a\nfirst-paint gutter update must not move the viewport underneath cached coordinates.\nFailure diagnostics record native move/press/activation messages and GUI-thread state.\nQuery `GetGUIThreadInfo(guiThreadId)` from the worker, not its own GetFocus/GetCapture;\nnever consume or reroute an event just to make the fixture pass.\nWindows workers acknowledge the press and threshold, then post a registered native message to\nthe source HWND. The GUI filter acknowledges it only while an owned `QDrag` exists. Send one\ndistinct bootstrap motion, await capture transfer from the fixture to a GUI-thread window,\nthen send the final destination; an already-posted capture never skips the bootstrap motion.\nAll waits share the original deadline. Qt timers or waiting for DragEnter before startup motion\ncan deadlock in Qt's filtered native pump. Destination arrival can be DragEnter or DragMove.\nRelease follows arrival or the bounded deadline; joined workers always attempt release and\nrestore the saved pointer. Cocoa\ncreates timestamped NSEvents at dispatch, without warping ahead of delivered source events.\nCreate native peers before measuring drag coordinates. On Cocoa, find the source viewport's\nnearest already-native QWidget ancestor without creating a peer. Its flipped QNSView coordinates\nmatch Qt's window coordinates: map locally to that ancestor, then use `convertPoint:toView:nil`\nfor the event and `convertRectToScreen:` plus the primary-screen Y flip for the Quartz pointer.\nDo not mix Qt's cached global frame origin into native input or compensate with titlebar offsets.\nTypst shortcut/focus tests use xcb under Xvfb on Linux, not offscreen. Keep the normal\nQtTest crash handler. Build the host Typst module's `test_typstviewwindow_insertion` executable\nonce, then select `^test_typstviewwindow_(insertion|cleanup|resources)$` in CTest. These serial\ngroups share the executable and each retain a 240-second deadline. With `VNOTE_TYPST_TEST_GROUP`\nunset, a direct executable launch runs all default-enabled cases. Each group writes plaintext stdout and\nits own `test_typstviewwindow_<group>.xml` JUnit file in the CTest working directory. CI\npreserves original parent/native CTest logs and these reports before diagnostic reruns,\nsince hosted stdout can omit assertions.\n\nThe controller executable is also partitioned without extending deadlines. Build\n`test_typstviewwindowcontroller` once and run\n`^test_typstviewwindowcontroller(_images|_resources)?$`: the base registration covers\nnavigation, followed by image validation/storage and resource acquisition/viewing.\nAll three are serial, retain 60-second deadlines and `-nocrashhandler`, and use the\nsame native runtime environment. Empty/unset `VNOTE_TYPST_CONTROLLER_TEST_GROUP` runs\nall cases directly; unknown groups fail. Slot boundaries `imageStorage()` and\n`resourceSourceRejection()` keep every test in exactly one group.\n\nThe `resourceSnapshotBytes(retained-gif-mime-not-replaced-file)` row is disabled by default.\nSet `VNOTE_TEST_RETAINED_GIF_SNAPSHOT=1` to register it; only the literal `1` enables it.\nThe PNG snapshot row and every other resource test remain enabled. Windows Clipboard\nUser Service can interfere with the GIF row on a shared desktop; the opt-in does not\nchange its assertions or convert failures into skips.\n\nAll three parent CI workflows set this flag, including for diagnostic reruns. **Pre-push\nverification must also set it when this suite is affected**; a default local pass is not\nproof of the full CI/pre-push coverage. No build reconfiguration is needed:\n\n```powershell\n$env:VNOTE_TEST_RETAINED_GIF_SNAPSHOT = \"1\"\nctest --test-dir build-debug -C Debug -R '^test_typstviewwindow_resources$' --output-on-failure\n```\n\n```bash\nVNOTE_TEST_RETAINED_GIF_SNAPSHOT=1 ctest --test-dir build-debug -R '^test_typstviewwindow_resources$' --output-on-failure\n```\n\nSet Qt's active window explicitly at focus-sensitive fixture operations; `activateWindow()`\nalone depends on a window manager under Xvfb. After dismissing a modal dialog, wait for\nwindow activation and editor focus before sending shortcuts. Settle native exposure and\nactivation before measuring drag coordinates. Before inserting tab-replacement or presentation\ndrafts programmatically, wait for editor focus: that registers the active writer used by Save.\nThe lazy-view `showWindow()` helper establishes those exposure/activation/focus preconditions;\ndo not require editor focus for hidden/disabled editors or encrypted buffers already locking.\nNative popup fixtures must settle the owner's activation before promotion/opening. A\ntransient parent shares `QWindow::isActive()`; when exact ownership matters, wait for both\n`QGuiApplication::focusWindow()` and `QApplication::activeWindow()` to match the intended host.\n`activateNativeHost()` observes the target's closest existing native surface, not only the\nroot window: fullscreen native children can cover the root's entire exposed region. Retain\ncurrent host/surface ancestry and exact native activation checks, without creating peers or\nforcing the child focus that a test is meant to observe. After modal cancellation, restore\nthe fixture's native top-level activation if needed, then observe automatic child-focus\nrestoration; never call `setFocus()` to supply the child focus under test. Native dialog\ninput similarly waits for `QGuiApplication::focusObject()`, not just QWidget `hasFocus()`.\nContext-menu fixtures wait for both selection and deselection to reach WebEngine before clicking.\nUse an explicit blank non-text region for an unselected context: Cocoa/Chromium can select\nthe word under a right-click even after the fixture cleared selection. Keep selected-link,\nimage and empty-context reset assertions distinct.\n\n`test_viewarea_tab_reuse` retains only its unmodified, version-stamped bundled asset directories\nbetween QtTest rows. It removes all local state and all non-bundle app entries before creating\na fresh context, config, notebook, services and WebEngine profile. Obtain cleanup paths from\nConfigMgr2, and prune only in the test class `init()`, never inside `Fixture::initialize()`:\nintra-row fixture recreation intentionally tests persisted sessions. This suite must not edit\ninstalled assets, including `web/css/user.css` or user-created templates; asset-customization\nfixtures must retain the full test-directory reset. Keep the existing suite deadlines.\n\nPDF.js text-item boundaries are not word boundaries: ligatures can split a word across\nitems. Reconstruct text by concatenating `item.str` and adding a newline only for\n`item.hasEOL`, as the bundled viewer does; do not insert spaces between every item.\n\n### Local pre-push flaky-test whitelist\n\nOn a **shared interactive Windows desktop only**, the following native-input failures\nmay be treated as non-gating for a local push. This is a closed list of failure sites,\nnot permission to skip an executable or any product-behavior assertion.\n\n| Executable | Exact QtTest case / row | Eligible failure site |\n|---|---|---|\n| `test_viewarea_tab_reuse` | `previewReaderNavigationAndNativeHost` | First source-reader `B` link's `clickNativeSurface` hit-test precondition |\n| `test_viewarea_tab_reuse` | `previewOpenInNewTabs(main-source)` | Open Link button's `clickNativeSurface` hit-test precondition |\n| `test_viewarea_tab_reuse` | `previewOpenInNewTabsDuplicateConsent(other-tab-save)` | Incoming editor's `hasFocus()` precondition before consent/promotion |\n\n**All** of these conditions are required for each use:\n\n1. Build the affected targets and attempt the **full executable** at the revision being\n   pushed. Preserve the first failure report. Windows GUI executables may omit QtTest\n   assertions from CTest's captured stdout; use explicit QtTest `-o <path>,txt` and\n   `-o <path>,junitxml` outputs for diagnosis with the registered runtime environment.\n2. Match both the exact row and failure site above. For a click failure, diagnostics must\n   show a visible, in-bounds target, an inactive native window, and null/foreign\n   `QApplication::widgetAt()` at the click position. For the editor-focus failure, also\n   establish that the native window was inactive. A bare `clickNativeSurface`/`hasFocus`\n   failure or a passing retry alone is **not** enough to classify the failure.\n3. The affected case must pass in isolation at the same revision, and all remaining rows\n   and other affected suites must run and pass. Prefer an exclusive desktop run when\n   available. Do not retry unchanged runs merely until one passes; reruns must investigate\n   a cause or follow an environment correction.\n4. The unpushed changes must not modify the affected native preview input, focus, geometry,\n   promotion, hit-test helpers, fixture activation or relevant platform configuration.\n   Those changes require a clean native verification run or a separate explicit user exception.\n5. Report the exact case, native-state evidence and local waived gate as a **skip**, not\n   a pass. Any other failure, including later content, routing, save/discard/cancel,\n   persistence assertions, crashes or timeouts, remains blocking even in a listed case.\n\nCI and packaging gates are never waived. Do not add `QSKIP`, exclude the executable or\nrows from CTest, weaken assertions, or change deadlines to implement this policy.\nReassess/remove an entry when its native activation cause is fixed.\n\nThe 2026-10-29 investigation observed a visible/in-bounds preview button with an inactive\nnative window and null `widgetAt`, while failing rows varied across runs; the unmodified\nfull suite subsequently passed all 73 cases. That earlier fixture requested activation\nwithout waiting for native activation; the current fixture observes its owned native surface.\nThe original reader-click rejection and editor-focus\nfailure did not identify a covering process or independently prove their native-window\nstate, so those assertion messages alone do not qualify for the exception.\n\n### VTextEdit.dll runtime copy\n\nTests that link `VTextEdit` (directly, or transitively via `core_configs`) load `VTextEdit.dll` at runtime. **`core_services` alone no longer pulls it in**, so a pure-core test that links only `core_services` + `vxcore` runs with no `VTextEdit.dll` anywhere near it. The build copies the DLL next to each subdirectory's test exes via a `POST_BUILD` step anchored on one test target per subdir (the canonical reference is `tests/utils/CMakeLists.txt:78-83`):\n\n```cmake\n# Copy VTextEdit DLL next to test executable so CTest can find it at runtime.\nadd_custom_command(TARGET test_clipboard_image POST_BUILD\n  COMMAND ${CMAKE_COMMAND} -E copy_if_different\n    $<TARGET_FILE:VTextEdit>\n    $<TARGET_FILE_DIR:test_clipboard_image>\n)\n```\n\nWith Typst enabled, `VTextEdit.dll` directly depends on `vtextedit_typst.dll`. The common\n`add_qt_test()` supplies Windows CTest lookup paths for Qt, VTextEdit and the native backend\nwithout linking pure-core tests to them. Tests replacing ENVIRONMENT must restore PATH\nafterward; append it separately to each test instead of copying an escaped PATH list.\n\n**When adding tests to a NEW subdirectory under `tests/` whose targets link `VTextEdit`**, that subdir's `CMakeLists.txt` MUST include this `POST_BUILD` block anchored on any one such test target. Without it, `ctest` reports the test as failed with exit code `0xc0000135` (Windows \"DLL not found\") and the test binary never reaches its `main()`. One copy per subdir is enough; `copy_if_different` makes incremental rebuilds free.\n\n### Running execs that depend on VTextEdit.dll (CRITICAL — avoid false-positive smoke tests)\n\n`build-debug/src/vnote.exe` and widget/editor tests load `VTextEdit.dll` and its native\n`vtextedit_typst.dll` dependency, plus the matching Qt 6 runtime. The editor/runtime DLLs\nare staged in `build-debug/bin` and beside VNote; Typst test targets also refresh their\nlocal copies. Pure-core tests linked only to `core_services` + `vxcore` remain independent\nof VTextEdit. Direct test launches and disposable app copies still need the configured\nQt/runtime lookup paths. A missing DLL can show a loader modal before `WinMain`.\n\n**This is a verification trap**: when the loader dialog blocks the process, the OS reports the process as alive (it has a PID, is technically running), so naive checks like `Start-Process … -PassThru` + `Sleep` + `HasExited` return `$false` → you falsely conclude the binary started. The process is actually frozen in pre-WinMain limbo waiting for someone to click the dialog. `Stop-Process -Force` afterwards silently dismisses the dialog and the lie is preserved.\n\n### Agent GUI smoke testing (Windows)\n\nUse `--no-single-instance` to test a separate VNote process without closing the user's running\ninstance. **It skips locking and IPC, not data isolation.** Never launch the agent's GUI smoke test\nagainst the normal profile or real notebooks. Create a fresh portable copy for each run; its\nexisting `config/` folder redirects config, session and WebEngine data to that copy.\n\nBefore using this recipe, check the OS Documents folder's `my_notebook` path. If it is missing or\nan empty directory, first-run setup can create/open a bundled notebook there, **outside the portable\ncopy**; use a disposable OS account instead. Do not edit, empty, or delete the user's directory to\nsatisfy the collision guard. Continue testing only the scratch file in the disposable copy.\n\nRun from the repository root after building `vnote`. Set `$build` to the build you just verified;\nthe example derives the matching Qt runtime from its CMake cache, rather than mixing Qt versions.\nRun both blocks in the same persistent PowerShell terminal, keeping it open during GUI inspection\nand cleanup; avoid a one-shot wrapper that captures inherited process pipes.\n\n```powershell\n$ErrorActionPreference = \"Stop\"\n$build = \"build-debug\" # Replace with the existing build you just compiled\n$qtDir = (Select-String -Path \"$build/CMakeCache.txt\" -Pattern '^Qt6_DIR:PATH=' |\n          Where-Object { $_.Line -notmatch 'NOTFOUND$' } | Select-Object -First 1).Line\nif (-not $qtDir) { throw \"No configured Qt directory in $build/CMakeCache.txt\" }\n$qtBin = [IO.Path]::GetFullPath((Join-Path ($qtDir -split '=', 2)[1] \"../../../bin\"))\n$env:PATH = \"$qtBin;\" + (Resolve-Path \"$build/bin\").Path + \";\" + $env:PATH\n$env:QT_QPA_PLATFORM_PLUGIN_PATH = Join-Path $qtBin \"../plugins/platforms\"\n$env:QTWEBENGINEPROCESS_PATH = Join-Path $qtBin \"QtWebEngineProcess.exe\"\n\n$smoke = Join-Path $build (\"agent-smoke-\" + [guid]::NewGuid().ToString(\"N\"))\nNew-Item -ItemType Directory -Path \"$smoke/config\" | Out-Null\nCopy-Item \"$build/src/vnote.exe\", \"$build/src/vnote_extra.rcc\", \"$build/src/qt.conf\" $smoke\nif (Test-Path \"$build/src/translations\") {\n  Copy-Item \"$build/src/translations\" \"$smoke/translations\" -Recurse\n}\nSet-Content -Path \"$smoke/smoke.txt\" -Value \"Disposable agent smoke-test note\" -Encoding UTF8\n$proc = Start-Process -FilePath \"$smoke/vnote.exe\" -PassThru `\n  -ArgumentList @(\"--no-single-instance\", \"--log-stderr\", \"`\"$smoke/smoke.txt`\"\") `\n  -RedirectStandardError \"$smoke/stderr.log\" `\n  -RedirectStandardOutput \"$smoke/stdout.log\"\nStart-Sleep -Seconds 5\n$proc.Refresh()\nif ($proc.HasExited) { throw \"VNote exited with $($proc.ExitCode); inspect $smoke/stderr.log\" }\nforeach ($module in @(\"VTextEdit.dll\", \"vtextedit_typst.dll\")) {\n  if (-not ($proc.Modules | Where-Object { $_.ModuleName -ieq $module })) {\n    throw \"$module not loaded: check runtime DLLs, not just process liveness\"\n  }\n}\nWrite-Output \"Inspect PID $($proc.Id): its own window must display smoke.txt and the note text\"\n```\n\nThe runtime check is only a prerequisite: inspect the **actual window and requested file**, and\nexercise the feature under test. The main window title can remain `VNote`; do not require the file\nname in that title. Startup/file-open diagnostics are in `$smoke/stderr.log`. Add `--detached-view`\nwhen testing detached windows. Use only scratch notes/notebooks and no production sync credentials.\n\nQuit through VNote's UI (answer **No** if asked to minimize to the system tray), allow save/sync\nworkers to drain, then clean up only this run's copy:\n\n```powershell\n$proc.WaitForExit()\nRemove-Item -LiteralPath $smoke -Recurse -Force\n```\n\nDo not reuse the generated profile for the next run: loading its saved session also performs\nnormal Windows launch-on-login reconciliation. Do not enable launch-on-login in a disposable copy.\nIn-app restart drops the flag and uses the normal guard; start another fresh copy explicitly instead.\nChanging `TEMP`/`TMP` does not isolate the fixed IPC name. For guard/forwarding tests use the unique\nserver/lock overrides in `test_singleinstanceguard`, never the user's production pipe or lock.\nBehavior contract: [Agent / manual testing bypass](../src/core/AGENTS.md#agent--manual-testing-bypass).\n\nThe same PATH prepend is required before running parent `test_*.exe` directly. `ctest --test-dir build-debug` inherits the caller's PATH, so set it once at the start of the session.\n\nvxcore submodule tests (`libs/vxcore/build_test/bin/Debug/test_*.exe`) do NOT depend on Qt or VTextEdit and need no PATH setup.\n\n## Adding New Tests\nUse the `add_qt_test()` helper function in CMakeLists.txt:\n\n```cmake\n# tests/module/CMakeLists.txt\nadd_qt_test(test_myclass\n  SOURCES\n    test_myclass.cpp\n    ${CMAKE_SOURCE_DIR}/src/module/myclass.cpp\n  LINKS\n    Qt6::Gui  # Optional: extra Qt modules\n  GUILESS     # Optional: do NOT link Qt6::Widgets\n)\n```\n\n### The `GUILESS` flag is load-bearing (linking only)\n\n`GUILESS` controls **one thing**: whether `add_qt_test` adds `Qt6::Gui` + `Qt6::Widgets` to the target. Non-GUILESS targets get them; GUILESS targets do not. This became load-bearing when `core_services` stopped linking `VTextEdit` (which used to leak `Qt::Widgets` in transitively) — see [`../src/core/services/AGENTS.md`](../src/core/services/AGENTS.md#core_services-is-qt-widgets-free-and-vtextedit-free-contract).\n\nIt does **not** — and cannot — choose between `QTEST_MAIN` and `QTEST_GUILESS_MAIN`. That is a source-level macro decision made at the bottom of the test `.cpp`. The two are related but independent:\n\n- A test using `QTEST_GUILESS_MAIN` that merely **includes** a QtWidgets header (without ever constructing a `QApplication`) is correctly labelled `GUILESS` and should list `Qt6::Widgets` explicitly in `LINKS`. `test_clipboard_image`, `test_exportcontroller`, `test_findunitedentry`, `test_missing_nodes_qt`, `test_duplicate_open_guard`, `test_newnotebookcontroller`, and `test_openvnote3notebookcontroller` all do exactly this.\n- Never \"fix\" a missing-QtWidgets compile error by relaxing `core_services`. Add the module the failing target actually needs to that target's `LINKS`.\n\n## Qt Test Macros\n\n| Macro | Purpose |\n|-------|---------|\n| `QTEST_MAIN(Class)` | Creates main(), runs tests with QApplication |\n| `QTEST_GUILESS_MAIN(Class)` | Headless test runner (preferred) |\n| `QVERIFY(condition)` | Assert condition is true |\n| `QCOMPARE(actual, expected)` | Assert equality |\n| `QFETCH(type, name)` | Fetch data-driven test value |\n| `QTest::addColumn<T>(\"name\")` | Declare data column |\n| `QTest::newRow(\"name\")` | Add data row |\n| `QSKIP(\"reason\")` | Skip test |\n| `QTest::ignoreMessage(type, msg)` | Suppress expected qDebug/qWarning/qCritical |\n\n## Test Data Fixtures\n\nTests that need realistic directory structures (e.g., legacy notebook migration) use on-disk fixtures under `tests/data/` instead of hardcoding JSON and file creation in C++.\n\n### Pattern\n\n1. **Create fixture directory** under `tests/data/` with real files on disk\n2. **Locate at runtime** using `QFINDTESTDATA` (works out of the box with Qt6+CMake)\n3. **Copy to temp dir** using `TempDirFixture::copyFrom()` for test isolation\n\n### Example\n\n```cpp\n// In your test class\nQString findFixture(const QString &p_relPath) {\n  // Path is relative to the test source file\n  QString path = QFINDTESTDATA(p_relPath);\n  QVERIFY2_RETURN(!path.isEmpty(),\n    qPrintable(QStringLiteral(\"Fixture not found: %1\").arg(p_relPath)),\n    QString());\n  return path;\n}\n\nvoid TestMyService::testWithFixture() {\n  // Locate fixture (relative to this .cpp file's directory)\n  QString fixturePath = findFixture(\n    QStringLiteral(\"../data/vnote3_notebooks/database_notebook\"));\n  QVERIFY2(!fixturePath.isEmpty(), \"Fixture not found\");\n\n  // Copy to isolated temp dir\n  TempDirFixture workDir;\n  QString sourceDir = workDir.copyFrom(fixturePath, QStringLiteral(\"source\"));\n  QVERIFY2(!sourceDir.isEmpty(), \"Failed to copy fixture\");\n\n  // Test against the copy\n  // ...\n}\n```\n\n### Adding New Fixtures\n\n1. Create directory under `tests/data/` with descriptive name\n2. Add all files the test needs (stubs are fine — content doesn't have to be real)\n3. For Chinese/Unicode filenames, the files must exist on disk with those names\n4. No CMake changes needed — `QFINDTESTDATA` resolves paths automatically via `QT_TESTCASE_SOURCEDIR`\n\n### Existing Fixtures\n\n| Fixture | Path | Description |\n|---------|------|-------------|\n| database_notebook | `tests/data/vnote3_notebooks/database_notebook/` | VNote3 notebook with 17 files (Chinese + ASCII), 1 subfolder, 3 attachment dirs |\n\n## Real Keychain Usage\n\nAll **new or modified tests** that instantiate the real `SyncCredentialsStore` (not a `MockCredentialsStore` / in-memory fake) MUST register a `tests::KeychainGuard` to track and clean up written PAT entries.\n\n### Why\n\nWithout deterministic cleanup, tests leak PAT entries into the host OS keychain. Fixed-ID tests overwrite themselves harmlessly, but UUID-based writes accumulate forever. QtKeychain has no enumerate API (see `src/core/services/synccredentialsstore.cpp:71`), so cleanup cannot be done by sweeping the keychain at startup; it must be driven by tracking each write.\n\n### How\n\n```cpp\n#include \"../helpers/keychain_guard.h\"\n\n// Class-level member:\ntests::KeychainGuard *m_keychainGuard = nullptr;\n\n// In initTestCase(), after registering SyncCredentialsStore:\nauto *credStore = m_services.get<vnotex::SyncCredentialsStore>();\nm_keychainGuard = new tests::KeychainGuard(credStore, this);\n\n// In cleanupTestCase(), BEFORE destroying the vxcore context:\nif (m_keychainGuard) {\n  m_keychainGuard->cleanup();\n  delete m_keychainGuard;\n  m_keychainGuard = nullptr;\n}\n\n// For indirect writes (e.g. via SyncService::enableSyncForNotebook),\n// also call track() defensively in case the credentialsStored signal races:\nm_keychainGuard->track(notebookId);\n```\n\n### Ordering\n\n`cleanup()` MUST run before the vxcore context is destroyed. The guard talks to `SyncCredentialsStore`, which depends on `ServiceLocator` and the live vxcore context; tearing the context down first leaves the guard with dangling references.\n\n### Cross-reference\n\nFor the prod-side cleanup contract (5 sites in `SyncService`), see [`../src/core/services/AGENTS.md` § Credential Cleanup Invariants](../src/core/services/AGENTS.md#credential-cleanup-invariants).\n\n### Mandatory `KeychainGuard` rollout (post-fix-failing-tests plan)\n\nAll sync tests that instantiate `SyncCredentialsStore` (whether through `ServiceLocator` or by local construction) MUST register a `tests::KeychainGuard`. This is non-negotiable: missing guards cause cumulative `notebook_sync_pat_<uuid>` accumulation in the Windows Credential Manager, eventually tripping Win32 error 8 (\"Not enough storage\") which cascades every sync test into failure on subsequent runs.\n\nAs of 2026-06-05, the following tests are compliant:\n\n- `test_synccredentialsstore` (canonical reference)\n- `test_sync_signal_auto_baseline`, `test_sync_signal_baseline`\n- `test_sync_hooks`, `test_sync_ops`, `test_sync_auto_route`, `test_sync_close_block`\n- `test_bootstrap_and_persist`\n- `test_syncservice`, `test_syncservice_lifecycle`\n\n(10 tests total.) New sync tests MUST follow the template in the \"Real Keychain Usage → How\" section above. Tests that do NOT touch the keychain (`test_eventbridge_sync`, `test_notebookcoreservice_sync`) are exempt.\n\n**Operator note (Windows):** the keychain entry surfaces in `cmdkey /list` as `LegacyGeneric:target=notebook_sync_pat_<uuid>` (CRED_TYPE_GENERIC). `KeychainGuard::cleanup()` delegates to `SyncCredentialsStore::deleteCredentials()`, which handles the full target name internally; operators do NOT need to manage the `LegacyGeneric:target=` prefix manually when invoking the guard.\n\n## Current Test Coverage\n\n| Test | Class | Test Cases |\n|------|-------|------------|\n| test_error | `Error`, `ErrorCode` | 23 |\n| test_exception | `Exception` | 20 |\n| test_pathutils | `PathUtils` | 68 |\n| test_htmlutils | `HtmlUtils` | 31 |\n| test_fileutils2 | `FileUtils2` | 15 |\n| test_configservice | `ConfigCoreService` | 10 |\n| test_notebookservice | `NotebookCoreService` | 33 |\n| test_bufferservice | `BufferCoreService` | - |\n| test_buffer | `Buffer2` + `BufferService` | 29 |\n| test_vnote3migrationservice | `VNote3MigrationService` | 46 |\n| test_searchservice | `SearchCoreService` | - |\n| test_servicelocator | `ServiceLocator` | - |\n| test_configmgr2 | `ConfigMgr2` | 7 |\n| test_hookmanager | `HookManager` | 24 |\n| test_hookintegration | Hook integration | 10 |\n| **Total** | | **267+** |\n\n## Debugging Tips\n\n### First-run Windows theme reports\n\nThe Windows CTest registration for `test_themeservice` writes\n`tests/gui/test_themeservice.txt` and `tests/gui/test_themeservice.xml` in the build tree,\nwhile retaining console output. CI includes both in `native-typst-test-report` before\nany diagnostic rerun can overwrite the local files. Inspect these original reports first;\na passing rerun does not establish that the first-run failure was repaired. File reports\ncapture QtTest diagnostics, not failures occurring before QtTest initializes.\n\n### Windows exit code `0xC0000409` is NOT a stack overrun\n\n`0xC0000409` (`STATUS_STACK_BUFFER_OVERRUN`) is misleading. On Windows, Qt's `qFatal()` calls `__fastfail(FAST_FAIL_FATAL_APP_EXIT)`, which the OS reports as `0xC0000409`. The actual cause is almost always a `qFatal()` deep inside Qt, e.g., `QFontDatabase: Must construct a QGuiApplication before accessing QFontDatabase` raised when a `QTEST_GUILESS_MAIN` test transitively touches font-database code.\n\nWhen a test exits with this code, **re-run it with file-based output capture** to surface the real Qt diagnostic:\n\n```powershell\n$env:PATH = \"C:/Qt/6.9.3/msvc2022_64/bin;\" + $env:PATH\n& \"build/.../tests/<category>/test_name.exe\" -o \"output.txt,txt\"\nGet-Content output.txt | Select-String \"QFATAL|FAIL\"\n```\n\nThe `QFATAL : ...` message line names the real cause. Do NOT chase the test for recursion, oversized stack allocations, or fixture loops; that is a false trail. Fix the underlying Qt precondition (switch to `QTEST_MAIN`, guard the GUI-dependent code path, or short-circuit before the offending Qt subsystem is touched).\n\n## Related Modules\n\n- [`../src/core/AGENTS.md`](../src/core/AGENTS.md) — Services and hooks being tested\n- [`../AGENTS.md`](../AGENTS.md) — Code style, architecture overview\n","isInternal":false,"tokens":9194,"sizeBytes":39149}],"systemPromptSnippet":"<yakaai_skills repo=\"vnotex/vnote\">\n<!-- File: .github/AGENTS.md (Tokens: ~6038 | Category: root-instruction) -->\n# CI, Packaging & Release\n\nThis directory holds the GitHub Actions workflows. **This document also governs\n[`src/Packaging.cmake`](../src/Packaging.cmake)**, which lives outside this directory and\ntherefore will not auto-load this file — open it explicitly when touching packaging, the\nWindows Qt 6 packages, or updater artifacts.\n\nSee also: [../AGENTS.md](../AGENTS.md) (repo-wide rules, submodule push discipline) and\n[../src/AGENTS.md](../src/AGENTS.md) (architecture, source-wide Qt patterns).\n\n---\n\n## Build execution\n\nLinux and macOS set `CMAKE_BUILD_PARALLEL_LEVEL=2` for both packaging and test\nbuilds; Make otherwise builds these large targets serially. Keep Cargo's separate\ntwo-job limit. In test-enabled jobs, run both parent and native-editor suites even when one fails,\nthen fail the step if either returned nonzero. Preserve both original CTest logs\nand the Typst JUnit reports before diagnostics; macOS artifact names include the Qt kit.\nUpload reports for successful test steps too: a QtTest case can skip inside an otherwise\npassing executable, so a green CTest result alone does not prove native input was exercised.\nDiagnostic reruns require the test step itself to have failed and a nonempty\n`Testing/Temporary/LastTestsFailed.log` in that suite's build tree. Parent and\nnative-editor histories are independent: `ctest --rerun-failed` without its own\nfailure history can rerun a passing suite after an unrelated failure.\n\nLinux, Windows and macOS allow 180 minutes for cold builds, full native test suites,\npackage gates and artifact publication. Keep the individual CTest deadlines; the job\nbudget is not a test timeout.\nmacOS creates and unlocks its disposable test keychain only after test compilation,\nright before running CTest. Cold builds must not consume the vault's unlock lease;\nrestore the original keychain before final packaging and signing.\n\nThe macOS matrix enables unit/native GUI tests only for Qt 6.10.3. Qt 6.9.3 sets\n`VNOTE_BUILD_TESTS=OFF` and skips the test build, execution and disposable test keychain.\nThe existing `[Release]` condition disables these suites for both kits. Both Qt kits still\nbuild and sign packages and run the signed-package ABI, consumer and WebEngine gates,\nincluding native Intel verification; do not describe Qt 6.9.3 as unit-test validated.\n\n## Interactive CI debugging\n\nUse this when a failure needs the real hosted OS, desktop, Qt kit or CI setup.\n`workflow_dispatch` accepts `debug_enabled=true` plus a single `debug_public_key`\n(`ssh-ed25519`). `.github/scripts/debug-session.py` uses pinned Upterm 0.39.0 and the\npinned `uptermd.upterm.dev` host-certificate authority. There is no web-shell or\nanonymous fallback. Never put a private key into inputs, logs or artifacts.\n\n### Dispatch from the local checkout\n\nThe examples use PowerShell 7.3+, an authenticated GitHub CLI, OpenSSH and Python.\nStart at the repository root, at the committed revision you intend to investigate.\nCreate a disposable key outside the checkout; keep diagnostic evidence outside this\nkey directory so it survives key cleanup:\n\n```powershell\n$debugDir = Join-Path ([IO.Path]::GetTempPath()) ('vnote-ci-ssh-' + [guid]::NewGuid().ToString('N'))\nNew-Item -ItemType Directory -Path $debugDir | Out-Null\n$key = Join-Path $debugDir 'id_ed25519'\nssh-keygen -q -t ed25519 -N '' -C vnote-ci-debug -f $key\nif ($LASTEXITCODE -ne 0) { throw 'Key generation failed' }\n$publicKey = (Get-Content -LiteralPath \"$key.pub\" -Raw).Trim()\n```\n\nUse a non-`master` branch at that revision. Debugging on `master` can resume into\ncontinuous-release publication with ad-hoc runner edits; debug mode does not disable\npublication. Follow the submodule push discipline before publishing the branch:\n\n```powershell\n$debugBranch = 'ci-debug-' + (git rev-parse --short HEAD).Trim()\ngit push --recurse-submodules=check origin \"HEAD:refs/heads/$debugBranch\"\nif ($LASTEXITCODE -ne 0) { throw 'Debug branch push failed' }\ngh workflow run ci-win.yml --ref $debugBranch `\n  -f debug_enabled=true -f \"debug_public_key=$publicKey\"\nif ($LASTEXITCODE -ne 0) { throw 'Debug dispatch failed' }\n```\n\nChoose the workflow and connection artifact for the required platform:\n\n| Workflow | Connection artifact | Pause point |\n|----------|---------------------|-------------|\n| `ci-win.yml` | `debug-connection-windows` | After tools, before configure/build |\n| `ci-linux.yml` | `debug-connection-linux` | After configure, before build and test dependencies |\n| `ci-macos.yml` | `debug-connection-macos-<Qt version>` | After configure, before test build/keychain setup |\n\nmacOS build jobs also use the `Mac-code-sign` environment. Any configured approval or\nbranch policy applies before SSH setup; inspect a waiting/blocked job before treating\na missing connection artifact as a helper failure. Do not switch to `master` to bypass\nan environment restriction.\n\nSet `$runId` to the ID in the dispatched run URL. If the CLI did not return a URL,\nuse `gh run list --workflow ci-win.yml --branch $debugBranch --event workflow_dispatch`\nand match the intended revision, not an unrelated latest run. Confirm `headSha` and\n`headBranch` with `gh run view $runId --json headSha,headBranch,jobs`.\n\nDownload once the connection-upload step succeeds; the artifact is available while\nthe job is still running. Do not wait for workflow completion to obtain it. Each\nmacOS matrix job has its own session/artifact, including the Qt 6.9 job whose unit\nsuite is disabled. Account for every paused job. No guest within 15 minutes ends a\nsession; the normal job deadline also continues to run.\n\n```powershell\n$connectionDir = Join-Path $debugDir 'connection'\ngh run download $runId --name debug-connection-windows --dir $connectionDir\nif ($LASTEXITCODE -ne 0) { throw 'Connection artifact download failed' }\n$connection = Get-Content (Join-Path $connectionDir 'connection.json') -Raw | ConvertFrom-Json\nssh-keygen -lf \"$key.pub\" -E sha256\n$connection.keyFingerprint\n```\n\nCompare the `SHA256:` fingerprint tokens, not the entire lines: the key comments can\ndiffer. The artifact contains `sshCommand`, `joinCommand`, `keyFingerprint` and\n`restoreEnvironment`; it contains no private key. Use fresh metadata for this run,\nnot an expired session address from an old log.\n\n### Connect with the pinned relay authority\n\nCreate a dedicated `known_hosts` from the checked-in helper's pin rather than trusting\n`ssh-keyscan`, accepting a new host key interactively, or disabling host-key checks.\n`runpy` below loads the definitions without invoking the helper's guarded `main()`:\n\n```powershell\n$knownHosts = (Join-Path $debugDir 'known_hosts').Replace('\\', '/')\npython -c \"import runpy,sys;sys.stdout.write(runpy.run_path('.github/scripts/debug-session.py')['KNOWN_HOSTS'])\" |\n  Set-Content -LiteralPath $knownHosts -Encoding ascii\nif ($LASTEXITCODE -ne 0) { throw 'Relay pin extraction failed' }\nif ($connection.sshCommand -notmatch '^ssh ([A-Za-z0-9_-]+@uptermd\\.upterm\\.dev)$') {\n  throw 'Unexpected SSH command; inspect connection.json without executing its contents'\n}\n$destination = $Matches[1]\n$sshArgs = @(\n  '-F', 'none', '-i', $key,\n  '-o', 'BatchMode=yes', '-o', 'IdentitiesOnly=yes', '-o', 'IdentityAgent=none',\n  '-o', 'ForwardAgent=no', '-o', 'StrictHostKeyChecking=yes',\n  '-o', ('UserKnownHostsFile=\"' + $knownHosts + '\"'), '-o', 'GlobalKnownHostsFile=none'\n)\nssh -tt @sshArgs $destination\n```\n\nThis attaches to the hosted shell (`pwsh -NoProfile` on Windows, clean Bash elsewhere),\ninitially in `GITHUB_WORKSPACE`. For agent automation, use a named PTY session and\nsend commands to that shell; wait for an explicit completion marker and exit code.\nA live PID, quiet terminal, or SSH disconnect does not prove command completion.\n\n### Restore the environment and reproduce the workflow\n\nThe helper removes checkout/submodule HTTP auth and credential-bearing URL rewrites,\nscrubs token/secret/key variables from the hosted process, and retains runner orphan\ntracking. Do not inject credentials back into the session to bypass this isolation.\nArtifact/signing steps keep their normal environment and run after session cleanup.\n\nUpterm redirects three XDG variables for its own state. Before measuring application\nbehavior, restore the original values in `connection.json`'s `restoreEnvironment`;\nJSON `null` means unset, not an empty or literal `null` value. On the Windows runner:\n\n```powershell\n$metadata = Join-Path $env:RUNNER_TEMP 'vnote-debug/connection.json'\n$original = (Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json).restoreEnvironment\nforeach ($entry in $original.PSObject.Properties) {\n  if ($null -eq $entry.Value) {\n    Remove-Item -LiteralPath ('Env:' + $entry.Name) -ErrorAction SilentlyContinue\n  } else {\n    [Environment]::SetEnvironmentVariable($entry.Name, $entry.Value, 'Process')\n  }\n}\n$build = Join-Path (Split-Path $env:GITHUB_WORKSPACE -Parent) 'build'\n```\n\nOn Bash, use `unset` for null entries and `export` with the recorded values otherwise.\nRestore only the hosted shell's application environment, not the helper's state files.\n\nRead the checked-out workflow's configure/build/test steps and reproduce their commands,\nworking directories and **step-local environment**. The build directory is the sibling\n`build` of `GITHUB_WORKSPACE`, not a directory inside the checkout.\n\n- **Windows:** the debug shell is not a Visual Studio developer shell. The earlier\n  `vcvars64.bat` invocation affected only its own `cmd` process; `VS_CMD` and\n  `VNOTE_TESTS` from later steps are not already set. Use the matrix's toolchain and\n  import its environment into the hosted shell, or run the entire command chain in\n  an initialized `cmd`. Match Release/Ninja and `VNOTE_BUILD_TESTS=ON` for test work.\n- **Linux:** test dependencies, D-Bus/keyring setup and the Xvfb/native-platform wrapper\n  come after the pause. Reproduce the workflow and `.github/scripts/run-linux-tests.sh`,\n  rather than replacing native GUI tests with `offscreen` or `minimal`.\n- **macOS:** preserve the configured Qt kit/architectures, normalize `TMPDIR` as the\n  workflow does, and create/unlock the disposable test keychain after compilation.\n  The Qt 6.9 configuration does not register the unit/native suites.\n- **Package-dependent tests:** build/package/extract prerequisites in the original\n  order. For example, Windows updater fixtures need the packaged installation; a\n  target-only build is not equivalent to the full CI setup. A debug-shell-only failure\n  (including PowerShell module lookup) is not automatically the original regression.\n\n### Preserve evidence before rerunning\n\nIn the build directory, inspect `ctest --show-only=json-v1` for the executable, arguments, working\ndirectory and test environment. Build the relevant target, then run its registered\nCTest command without changing assertions, deadlines or platform behavior. A focused\nWindows example, after the workflow's configure/toolchain prerequisites:\n\n```powershell\ncmake --build $build --target test_themeservice\nif ($LASTEXITCODE -ne 0) { throw 'Theme test build failed' }\nctest --test-dir $build -C Release -R '^test_themeservice$' --output-on-failure --no-tests=error\n$testExit = $LASTEXITCODE\nWrite-Output \"DEBUG_CTEST_EXIT=$testExit\"\n```\n\nCapture the **first** failure to files before standalone repetitions or full-order\nexperiments overwrite it. Keep CTest's `Testing/Temporary/Last*.log`, QtTest text/JUnit\nreports, exit code, revision and relevant environment. The Windows theme registration\nalready writes `tests/gui/test_themeservice.txt` and `.xml` in the build tree. For other\nQtTest executables, use unique `-o <file>,txt` and `-o <file>,junitxml` outputs while\nretaining `-o -,txt` and the registered runtime environment. File loggers cannot explain\na failure before QtTest initializes. Inspect skips as well as pass counts.\n\nExport evidence while the session is reachable: from another local terminal with the\nsame client variables, use `sftp @sshArgs $destination` (without SSH's `-tt`) and download\nthe files to a separate evidence directory. Resolve remote paths with SFTP's `pwd`/`ls`\ninstead of guessing Windows drive mappings. Ad-hoc files are not automatically uploaded\nby the workflow; normal report-upload steps run only after the debug pause ends.\n\nA relay disconnect can leave the runner and build alive. Check the run's job state and\nreattach to the same session if still reachable; inspect the saved completion marker\nand files instead of restarting the build blindly. If standalone runs pass, compare\nnormal CI setup/order before proposing production or timing changes. Ad-hoc debug\npasses do not replace a clean CI run of the committed fix.\n\n### Close the session and remove the key\n\nRun `exit 0` **inside the hosted shell** to end it and resume the workflow. Closing the\nlocal SSH client alone merely disconnects; it does not end the pause. Close each macOS\nmatrix session you opened and account for any other paused matrix jobs.\n\nIf the relay cannot be recovered, cancel the job with `gh run cancel $runId`, then\nawait shutdown with `gh run watch $runId --exit-status` (cancellation returns nonzero).\nVerify the `Close SSH debug session` step succeeded via `gh run view $runId --json jobs`;\ndo not assume a lost connection means the exposed session is gone. Once closure is\nconfirmed and evidence is saved outside `$debugDir`, remove the local disposable key:\n\n```powershell\nRemove-Item -LiteralPath $debugDir -Recurse -Force\n```\n\n## Release packaging dependencies\n\n`pack` must build `cmark_exe` before CPack runs: upstream cmark installs its CLI\nunconditionally, even though Windows package pruning removes it afterward. Release\nCI deliberately skips `all` to avoid building tests, so do not rely on a prior\nall-target build or weaken the install/pruning checks to hide a missing executable.\n\n### Native Typst runtime and attribution\n\nVNote requires the pinned Rust 1.99.0/Typst 0.15.1 shared library and Qt PDF. Embedding forces\n`VTEXTEDIT_ENABLE_TYPST=ON` and `VTEXTEDIT_BUILD_DEMOS=OFF`; do not ship upstream demos.\n`vnote_typst_runtime` always refreshes the native runtime (and VTextEdit.dll on Windows),\neven without relinking VNote. Both `pack` and direct `deploy` depend on this target and\n`prepare_typst_notices`, retaining the existing cmark/lrelease prerequisites.\n\nProvision `cargo +1.99.0 install cargo-about --locked --version 0.9.2 --features cli` and\nfetch the backend's complete locked dependency graph before offline deployment. The `cli`\nfeature is required: installing this release without it produces no executable. Keep\n`CARGO_BUILD_JOBS=2` and OS/Rust/lockfile-specific Cargo caches; Apple keys also include\nthe deployment target. Non-release native editor tests run from `build/libs/vtextedit`\nwith `--no-tests=error`, not just the parent's CTest root.\n\n`package/prepare-typst-notices.py` uses `package/typst-about.toml` to generate full Rust\nlicense texts, including verbatim typst-assets/font notices and the backend LGPL text.\nMatching `sbom/qtpdf-<version>.spdx.json` and\n`sbom/qtwebengine-chromium-pdf-<version>.spdx.json` are required SDK attribution inputs.\nQt 6.9.3 provides both when qtpdf and qtwebengine are installed; the Chromium PDF inventory\nis in qtwebengine. Missing/empty inputs fail packaging; never substitute another Qt version.\nGeneration is deployment-only, with no dependency installation at configure or runtime.\nNotices ship in Windows `licenses/typst`, Linux `share/licenses/vnote/typst`, and macOS\n`Contents/Resources/licenses/typst`.\n\nWindows deployment scans both vnote and VTextEdit for Qt6Pdf and keeps\n`vtextedit_typst.dll` in the flat runtime directory, never the pruned lib/bin directories.\nLinux scans the installed `libvtextedit_typst.so.1` as a linuxdeploy library and checks\ninstall/deploy exit codes. Universal macOS libraries build both Rust triples with the job's\ndeployment target, then lipo once. Stage `libvtextedit_typst.1.dylib` explicitly in\n`Contents/Frameworks` through vnote_typst_runtime/deploy, fix load paths and sign before\ndeep bundle signing; ordinary install() does not assemble the VNote app. Resolve the\npackaged VTextEdit filename from\nVNote's linked dependency, not an unversioned dylib name; rpath cleanup and final DMG checks\nmust inspect that same library. Remove only rpaths outside the final DMG path allowlist.\nInspect both slices' minimum-OS metadata and reject build-tree dependencies. Metadata from a newer runner is not a Monterey launch test.\nRun `scripts/e2e/smoke_typst_package.py --library <extracted-library>` against final package\ncontents without developer Cargo/Qt/Typst overrides. It calls C ABI 1 in a supervised\nPython test worker and verifies real PDF/heading output and reply lifetime after session\ndestruction. Also launch the packaged consumer and inspect native dependency closure;\nctypes alone does not prove VTextEdit/VNote linkage. Reject the old Typst executable.\nOn macOS, architecture probes and ABI workers reuse the gate's already-running Python\n(`sys.executable`), not a different `/usr/bin/python3` runtime. Keep the 15-second architecture\nprobe, mandatory native-architecture execution, and existing non-host availability policy.\n`scripts/e2e/smoke_macos_consumer.py` requires the expected native architecture and launches\nVNote with the unchanged 30-second budget and an isolated home. Test-enabled builds run ARM\nnative suites; all builds retain both available ABI slices and the WebEngine gate. Separate\n`macos-15-intel` jobs download\nthe same signed DMGs and require native x86_64 ABI and consumer execution for both Qt kits.\nCold Rosetta translation of QtWebEngineCore can exceed the launch budget before main; do not\nwarm its cache, retry to green, or increase the deadline instead of using native Intel proof.\nStaged DMG artifacts are inputs to verification, not publication approval. Both continuous and\nversioned publication depend on all ARM build/gates and both Intel verification jobs, and\npublish the unchanged signed artifacts. Native macOS 15 Intel proof is not a Monterey test.\nA packaged-consumer timeout remains a failure. Capture bounded native stack/screen diagnostics\nunder `RUNNER_TEMP`, terminate only that unreaped probe's own process group, and retain the\nmatrix-specific diagnostic artifact. Include open-file, memory-map and bounded read-only\nRosetta-daemon samples to locate pre-main loader stalls; pre-create privileged sample output\nas the runner user. A separate one-day `failed-package-macos-<Qt version>` artifact retains\nonly the exact signed DMG for reproduction, never adjacent certificates/keychains or the build\ndirectory. It is diagnostic-only, not a release. Do not mask the timeout, force-unmount a busy\npackage, or move SSH sessions past signing-secret provisioning.\n\n## macOS runtime composition\n\nRun `package/prune-macos-package.cmake` with `VNOTE_APP_DIR` after each deployment\npass and after the last CI framework fixup, **before signing**. CPack's external\nmacOS packager rewrites the build bundle; Windows install-staging pruning does not\napply. Complete all test builds before final `pack`/deployment: those builds can relink\nVNote/VTextEdit and restore build-tree RPATHs. Both framework-fixup passes precede the\nfinal RPATH cleanup and pruning; no rebuild follows before signing. Use `VERIFY_ONLY=ON`\non the mounted signed DMG; it must never mutate it.\n\nRemove `PlugIns/imageformats/libqpdf.dylib`, `PlugIns/position/libqtposition_nmea.dylib`\n(and their `_debug` variants), `PlugIns/sqldrivers`, `QtSql.framework`, and\n`QtSerialPort.framework`. VNote and its widget tests must not require QtSql; vxcore\nuses native SQLite instead. Keep qtserialport in the CI SDK so macdeployqt can scan\nthe NMEA plugin before pruning. SQL-driver Homebrew libraries/workarounds are not needed.\nKeep QtPdf for Typst, QtPositioning and other positioning plugins, TLS backends,\nQML/Quick/WebEngine, DevTools, universal slices and attribution files.\n\nOnly `en-US.pak`, `zh-CN.pak`, `ja.pak`, and `ko.pak` ship in\n`Contents/Frameworks/QtWebEngineCore.framework/Versions/A/Resources/qtwebengine_locales`.\nMissing/empty retained packs or another locale directory fail packaging. Do not\nprune sibling WebEngine resource paks, ICU data, snapshots or the process helper.\nQt's resource fallback supplies English for unsupported locales without changing\nVNote's application locale or date/number formatting.\n\nThe final-DMG gate rejects load commands to the removed frameworks in **all**\nMach-O files. `scripts/e2e/smoke_webengine_package.py` compiles a disposable native\nprobe against the SDK but runs against the signed package: separate processes for\neach supported locale plus German/British-English fallback, actual DOM rendering,\nand mapped framework/locale paths in both browser and helper. It must not resolve\nruntime resources from the SDK or ship its probe in the app.\nBuild the probe with Qt's imported CMake targets, including their transitive header modules,\nnot a hand-maintained framework/include list. Declare the ARM yield intrinsic before Qt headers\non ARM hosts. Configure and compile share one 120-second budget; target only the host architecture.\nUse `BUILD_WITH_INSTALL_RPATH` with the signed package Frameworks directory and no SDK link-path\nRPATH additions. Keep the 45-second render and 15-second shutdown bounds and all locale checks.\n\n## Windows runtime (Qt 6)\n\nWindows packages use Qt 6 only and publish the `win64` artifact family. Qt uses\nits native Schannel TLS backend; Git sync uses WinHTTP. Do not bundle OpenSSL\nDLLs or a separate UCRT redistributable: supported Windows versions provide UCRT.\nKeep the MSVC runtime deployment and its fallback for newer toolsets, the flat\npackage layout, and the SDK/stray-executable composition gates. WiX stages the\napplication under `Unspecified/`; pruning resolves that component root before\nchecking for `vnote.exe`. ZIP staging remains flat.\n\nDeploy both VNote and VTextEdit without `--no-pdf`: Typst requires `Qt6Pdf.dll`.\nPrune only the unused `imageformats/qpdf.dll` plugin and NMEA position plugin after\ndeployment (including their debug variants); NMEA needs SerialPort, and Qt 6.10.3\nstill deploys it with `--exclude-plugins`. Keep `Qt6Positioning.dll`, the other\npositioning plugins, and WebEngine's QML/Quick/OpenGL dependencies.\nDo not prune the dynamically loaded D3D/DXC compilers or the MSVC runtime set.\nThe extracted-ZIP composition gate rejects PDF image plugins, NMEA, and application-local\nUCRT/API-set DLLs recursively; the Typst gate requires a nonempty `Qt6Pdf.dll`.\nKeep `qtpdf` in the CI SDK for the required runtime and its attribution.\n---\n\n## Update artifacts\n\nOrdinary startup/manual checks remain metadata-only and read-only for the installation;\n`UpdateService` ignores assets. The sole installation entry point is the explicit Windows\n`Update Now` notification action launching the deployed external PowerShell updater, not an\nin-process installer or a manual-dialog action. The script consumes signed **full packages\nonly**, with no unsigned or cross-source fallback. See\n[../docs/update-signing.md](../docs/update-signing.md) and root\n[AGENTS.md § Update Check](../AGENTS.md#update-check).\n\nOn the existing `[Release]` master-push condition, `win64` must publish a ZIP,\n`.manifest.json` and `.manifest.json.minisig`. Retain optional delta publication,\nalthough this updater never consumes deltas. Preserve the stable-channel and\nartifact-list gates. Missing `MINISIGN_SECRET_KEY` must fail release signing, and\nsecret files stay outside the workspace. Missing Gitee mirrors fail closed at\nruntime; never switch the user's source to GitHub.\n\n`prepare_win_updater` must run `package/prepare-win-updater.cmake` with\n`UPDATER_OUTPUT_DIR=<build>/src/win-updater` as a **deploy-only prerequisite**: no network on\nconfigure or ordinary builds. Pin minisign 0.11 archive, executable and upstream ISC license\nby SHA-256; accept only hash-matching cache entries and extract only in the build tree.\nSigning uses this prepared verifier, not a second unpinned download.\n\nInstall `update-vnote.ps1`, `minisign.exe` and `LICENSE.minisign` under `updater/`, all\nnon-optional; retain the existing libsodium notices. Keep the root executable allowlist at\n`vnote.exe` and `QtWebEngineProcess.exe`. Require `vtextedit_typst.dll` separately.\nThe extracted-package gate must\nassert all three helper files and run the **packaged** `updater/minisign.exe -v`,\nprepending that package root to the subprocess PATH for its runtime DLLs. Retain\nall Qt 6 package-composition checks; verifier presence alone is not runtime proof.\n\n## Linux test runtime\n\n`ci-linux.yml` installs test-only runtime dependencies after packaging and runs\nboth the suite and failure diagnostics through `.github/scripts/run-linux-tests.sh`.\nThe wrapper creates a private D-Bus session and unlocked GNOME keyring, verifies\nSecret Service with a write/read/delete probe, then tears down the daemon and\nprivate XDG directories. CTest runs under Xvfb so tests selecting the native xcb\nbackend have a display. Install `xvfb` and `xauth` unconditionally: the final AppImage\nconsumer check also needs them on release builds. It launches packaged `AppRun --help`\nwith `QT_QPA_PLATFORM=xcb` under `xvfb-run -a`; the AppImage ships xcb, not offscreen.\nKeep Qt lookup inside the extracted package and fail on plugin/display/load errors.\nThe child `test_typsteditor` suite must select `QT_QPA_PLATFORM=xcb` explicitly:\nits native layout/raster scenarios must not inherit the parent's offscreen default.\nKeep the credential tests on a real vault; headers from\n`libsecret-1-dev` alone do not provide one.\n\nTLS fixtures use Ubuntu's `/usr/bin/openssl` explicitly, not a PATH executable\nshadowed by the separate packaging OpenSSL install. These environment changes\napply only to tests; release commits skip vault setup and test invocations, not the\nXvfb-backed packaged-consumer check.\n\n## macOS test temporary paths\n\nBoth non-release test launches resolve `TMPDIR` to its physical path before\nstarting Qt or Python fixtures. The system `/var` alias resolves to `/private/var`\nand otherwise trips the intentional sync path-safety checks. Keep those guards\nand deliberate symlinks inside test fixtures unchanged; only the outer temporary\nroot is normalized, without affecting build or packaging paths.\n\n<!-- File: AGENTS.md (Tokens: ~5257 | Category: root-instruction) -->\n# VNote Agent Development Guide\n\nRoot routing document: prerequisites, build, repo-wide rules, and an index of the module docs.\nModule-specific detail lives in the child `AGENTS.md` that owns the code — see\n[Module Documentation Index](#module-documentation-index).\n\n## Prerequisites\n\n- **Git** (with Git Bash on Windows)\n- **CMake** 3.21+\n- **Qt** 6.9+ (with QtWebEngine and Core5Compat)\n- **C++20-capable compiler** (MSVC, GCC, Clang) for the syntax indexer; VNote uses C++17\n- **Python 3** for syntax catalog generation\n- **clang-format** (optional, for automatic code formatting)\n\n## Setup\n\nAfter cloning the repository, run the init script:\n\n| Platform | Command |\n|----------|---------|\n| Linux/macOS | `bash scripts/init.sh` |\n| Windows | `scripts\\init.cmd` |\n\nThe init script:\n1. Initializes and updates git submodules recursively\n2. Installs pre-commit hook for automatic clang-format on staged C++ files\n3. Sets up vtextedit submodule pre-commit hook\n\n## Build Commands\n\n### Release Build\n```bash\nmkdir build && cd build\ncmake ..\ncmake --build . --config Release\n```\n\n### Debug Build\n```bash\nmkdir build && cd build\ncmake .. -DCMAKE_BUILD_TYPE=Debug\ncmake --build . --config Debug\n```\n\n### Windows (PowerShell)\n```powershell\nNew-Item -ItemType Directory -Force -Path build\nSet-Location build\ncmake .. -GNinja\ncmake --build . --config Release\n```\n\n### Clean Build\n```bash\nrm -rf build && mkdir build && cd build && cmake .. && cmake --build .\n```\nNever repair a stale build dir in place.\n\n### Building the Tests\nTests are **not** built by default (`VNOTE_BUILD_TESTS=OFF`), so a plain `cmake ..` produces the\napp only. CI enables them explicitly. To build them locally:\n```bash\ncmake .. -DVNOTE_BUILD_TESTS=ON\ncmake --build .\n```\n\nTesting (two suites, two build dirs): [tests/AGENTS.md](tests/AGENTS.md).\n\n### Pre-push verification (REQUIRED)\n\nBefore pushing parent-repository or submodule changes, agents MUST:\n\n1. Determine the affected build and test targets from **all unpushed changes** relative\n   to the destination branch, not just the latest commit. Trace shared headers/APIs,\n   build configuration and submodule pins to dependent consumers; filenames alone\n   are not sufficient. Broaden coverage when the impact cannot be bounded safely.\n2. Successfully build the affected application/library targets and **all test targets**\n   with tests enabled, using a compatible Qt kit, compiler and build configuration.\n   Include targets excluded from the default build and native-editor or standalone\n   submodule test targets in their own build directories. An app-only build does not\n   verify tests. Follow [tests/AGENTS.md](tests/AGENTS.md) for test build configuration.\n3. Verify the final code/configuration being pushed; rebuild required targets after\n   subsequent changes. Report the scope rationale, build commands, results, skips and\n   platform limits. If all unpushed changes are documentation-only, check the docs and\n   state that no build/test targets are affected; compilation and tests are not required.\n\n**Running tests is not required before pushing.** Successful compilation and linking of\nall test targets is sufficient for the local test verification gate; CI test requirements\nremain unchanged.\n\n**Do not push when required builds fail, time out or cannot run.** Fix the cause and\nverify again; never disable tests or exclude required targets just to obtain a successful\nbuild. If build verification is unavailable, report the exact blocker and obtain an\nexplicit user exception before pushing. A local build is not proof that tests pass or\nthat other operating systems or packaging gates pass; disclose that limitation.\n\n### CI runner debugging (SSH)\n\nWhen a failure needs the hosted runner, follow the key-restricted SSH procedure in\n[CI debugging](.github/AGENTS.md#interactive-ci-debugging). Dispatch a non-publishing\nbranch at the intended revision, preserve first-run evidence, and confirm session closure.\nAn ad-hoc debug pass is not proof that normal CI is fixed.\n\n---\n\n## Submodule Push Discipline (CRITICAL — read before every push)\n\nVNote pins git submodules (`libs/vxcore`, `libs/vtextedit`, `libs/QHotkey`, `libs/qwindowkit`)\nto specific commits. **CI clones submodules from their own remotes**, so a commit that exists\nonly locally fails every CI job at \"Init Submodules\" (`upload-pack: not our ref <sha>`) before\nany build runs.\n\n### Rule: ALWAYS push the submodule FIRST, then the parent repo.\n\n```bash\ncd libs/vxcore\ngit push origin HEAD:main      # or the appropriate branch\ncd ../..\ngit push                       # only now may the gitlink be pushed\n```\nTo push both at once with verification, use `git push --recurse-submodules=on-demand`, or enforce\nit once per clone:\n\n```bash\ngit config push.recurseSubmodules check   # aborts the parent push if a submodule commit is unpushed\n```\n\n### Before pushing, verify no submodule commit is stranded\n\n```bash\n# For each submodule, confirm local HEAD is not ahead of its remote:\ngit submodule foreach 'git status -sb'\n# A line like \"## main...origin/main [ahead 1]\" means an UNPUSHED submodule commit — push it before pushing vnote.\n\n# Confirm the pinned SHA exists on the submodule remote:\ncd libs/vxcore && git branch -r --contains $(git rev-parse HEAD) && cd ../..\n# Empty output = the commit is NOT on any remote branch yet. DO NOT push vnote until it is.\n```\n\nIf CI is already failing with \"not our ref\", the fix is to push the missing submodule commit (do\n**not** roll back the parent pointer if newer parent commits depend on the new submodule API).\n\n### cmark is vendored twice — bump both pins together\n\nThe same cmark fork (`https://github.com/vnotex/cmark.git`) is pinned by two different submodules:\n\n| Parent submodule | Nested cmark path |\n|---|---|\n| `libs/vtextedit` | `libs/cmark` |\n| `libs/vxcore` | `third_party/cmark` |\n\nOnly **one** is ever compiled in: `libs/CMakeLists.txt` adds `vtextedit` first, which defines the\n`cmark` target unconditionally, so vxcore's `if(NOT TARGET cmark)` guard skips its own copy.\nDiverged pins silently build vxcore against an untested cmark, with no error or warning.\n**Bump both submodules to the same cmark commit in a single change.** [`tests/utils/test_cmark_pin_drift.cpp`](tests/utils/test_cmark_pin_drift.cpp) fails the\nbuild when the two recorded pins differ.\n\n---\n\n## Architecture Overview\n\nVNote uses a **clean architecture** with **Model-View-Controller (MVC)** pattern and dependency\ninjection. Models hold data, Views display it, Controllers handle logic, Services own domain\noperations, and every layer receives a `ServiceLocator&` — there are no singletons.\n\n| Layer | Location | Responsibility | Example |\n|-------|----------|----------------|---------|\n| **Model** | `src/models/` | Data representation, Qt Model/View integration | `NotebookNodeModel` exposes node hierarchy via `QAbstractItemModel` |\n| **View** | `src/views/` | Display data, capture user input, emit signals | `NotebookNodeView` renders tree, emits `nodeActivated` signal |\n| **Controller** | `src/controllers/` | Handle actions, orchestrate Model/View, business logic | `NotebookNodeController` handles new/delete/rename operations |\n| **Service** | `src/core/services/` | Domain operations, data access via vxcore | `NotebookCoreService` wraps vxcore C API for notebook CRUD |\n\n### MVC Rules (MUST FOLLOW)\n\n| Rule | Rationale |\n|------|-----------|\n| **Models MUST NOT** contain UI logic | Models are reusable across different views |\n| **Views MUST NOT** modify data directly | Views only display and emit signals |\n| **Controllers MUST NOT** inherit from QWidget | Controllers are testable without GUI |\n| **All layers receive `ServiceLocator&`** | Enables dependency injection and testing |\n| **Use signals/slots between layers** | Loose coupling between M, V, C |\n\nFull diagram, directory tree, design-decision rationale (including the ViewArea2 framework), and\nsource-wide Qt patterns: see [src/AGENTS.md](src/AGENTS.md).\n\n---\n\n## Code Style Guidelines\n\n### Standards\n- **C++17** standard\n- **Qt 6** framework\n- CMake with `CMAKE_AUTOMOC`, `CMAKE_AUTOUIC`, `CMAKE_AUTORCC` enabled\n\n### Formatting\n- 2-space indentation\n- 100 character line limit\n- Pointer alignment right: `int *ptr`, not `int* ptr`\n- Use provided `.clang-format` (auto-applied via pre-commit hook)\n\n### No Hardcoded Colors (enforced)\n\n**Never write a literal color into a `setStyleSheet()` call.** VNote ships 12\nthemes, 6 of them dark; a hardcoded `#RRGGBB`, `rgb()/rgba()` literal, or CSS\ncolor name is correct only in whichever theme its author was running, and it\ncannot follow a runtime theme switch.\n\n`tests/utils/test_hardcoded_color_drift.cpp` is a grep gate over `src/` that\nfails the build on any **stylesheet string** literal containing both a CSS color\nproperty and a literal color value (colors used as *data*, and `QColor` painted\nin a `paintEvent`, are out of scope).\n\nUse `InlineBanner`, the `SeverityText` / `MutedText` dynamic properties, a rule\nin each theme's `interface.qss`, or `ThemeService::paletteColor()`. Do **not**\nuse `setEnabled(false)` to mute text. See\n[src/widgets/AGENTS.md § No Hardcoded Colors in C++](src/widgets/AGENTS.md#no-hardcoded-colors-in-c)\nfor the decision table and the escape hatch.\n\n### Tooltip Copy\n\nTooltip text MUST NOT end with sentence-ending punctuation. Keep punctuation needed between\nsentences or clauses; write parenthetical qualifiers such as `(restart required)` in lowercase\nwithout terminal punctuation.\n\n### Naming Conventions\n\n| Element | Convention | Example |\n|---------|------------|---------|\n| Classes | CamelCase | `ConfigMgr`, `MainWindow` |\n| Methods | camelCase | `getInst()`, `initLoad()` |\n| Parameters | `p_` prefix | `p_parent`, `p_config` |\n| Members | `m_` prefix | `m_themeMgr`, `m_config` |\n| Constants | `c_` prefix | `c_orgName`, `c_appName` |\n| Getters | `get` prefix | `getThemeMgr()`, `getName()` |\n\n### Include Order\n```cpp\n#include \"ownheader.h\"      // Own header first\n\n#include <QDateTime>        // Qt includes\n#include <QObject>\n\n#include \"localheader.h\"    // Local includes\n#include <core/configmgr.h>\n#include <utils/utils.h>\n\nusing namespace vnotex;     // Namespace declaration in .cpp\n```\n\n### Header Guards\n```cpp\n#ifndef CLASSNAME_H\n#define CLASSNAME_H\n// ...\n#endif // CLASSNAME_H\n```\n\n### Namespaces\n\nVNote uses a single `vnotex` namespace. Services that wrap the vxcore C library use the `CoreService` suffix to distinguish them from higher-level wrapper services:\n\n| Class Pattern | Purpose | Examples |\n|---------------|---------|----------|\n| `XXXCoreService` | Low-level services that wrap the vxcore C library (hold `VxCoreContextHandle`) | `ConfigCoreService`, `NotebookCoreService`, `BufferCoreService`, `SearchCoreService`, `FileTypeCoreService` |\n| Other classes | Everything else: UI, controllers, models, hook-aware wrapper services | `BufferService` (hook wrapper), `HookManager`, `TemplateService`, `ConfigMgr2`, controllers, widgets |\n\n**Rules:**\n- `using namespace vnotex;` in `.cpp` files only, never in headers\n- Forward declarations preferred in headers\n\n### Signal/Slot Connections\n```cpp\n// Preferred: typed signal/slot syntax\nconnect(m_taskMgr, &TaskMgr::taskOutputRequested,\n        this, &VNoteX::showOutputRequested);\n\n// With overloaded methods\nconnect(this, &VNoteX::openNodeRequested, m_bufferMgr,\n        QOverload<Node *, const QSharedPointer<FileOpenParameters> &>::of(&BufferMgr::open));\n```\n\nMemory management, queued-connection metatype registration, and the rest of\nthe source-wide patterns: [src/AGENTS.md § Source-Wide Qt Patterns](src/AGENTS.md#source-wide-qt-patterns).\nNoncopyable, `VNOTEX_DEPRECATED` and exception handling: [src/core/AGENTS.md](src/core/AGENTS.md#core-c-facilities).\n\n---\n\n## Sync State Model\n\nNotebook sync has 8 reachable states (S0-S7), defined by the tuple of on-disk JSON sync fields,\ncredential presence in the OS keychain (Git PAT or WebDAV login), and runtime registration in vxcore's `states_` map. **S5 is the\nonly \"ready\" state**; S1-S4 and S6 are partial/inconsistent, S0 is cleanly disabled, S7 is\nin-flight. Every controller, widget, and service that touches sync must reason in these terms.\n\nFull predicate table, recovery paths, reconcile semantics, disable cleanup, the S6 startup sweep,\nand the Qt-side scheduling shape:\n[src/core/services/AGENTS.md § Sync State Model](src/core/services/AGENTS.md#sync-state-model).\nvxcore-side threading contract:\n[libs/vxcore/src/sync/AGENTS.md](libs/vxcore/src/sync/AGENTS.md).\n---\n\n## Save Path Threading Contract\n\nBuffer saves run on a worker via `BufferSaveQueue`; save and git-stage/commit work on the SAME\nnotebook are serialized by the per-notebook `NotebookIoGate` async mutex. WebDAV additionally installs incoming changes under\nthe gate after a GUI-owned buffer reservation and save drain; metadata refresh remains GUI-thread-only.\n\n> **Forbidden Patterns (post-T7):**\n> - Calling `vxcore_buffer_save` directly from the UI thread. Use [`BufferSaveQueue::enqueue`](src/core/services/buffersavequeue.h) instead.\n> - Touching a notebook's working tree (save, stage, commit, checkout) without holding `NotebookIoGate::ScopedLock(notebookId)`.\n\nFull rationale and the two-phase sync gate:\n[src/core/services/AGENTS.md § Save Path Threading Contract](src/core/services/AGENTS.md#save-path-threading-contract).\n\n---\n\n## Cross-Notebook Node Transfer\n\nCross-notebook copy and move operations must go through\n[`NodeTransferService`](src/core/services/nodetransferservice.h). VNote owns editor/comment\ndurability, sync exclusion, IO-gate ordering, hooks, clipboard policy, progress, and UI feedback;\nvxcore owns snapshotting, identity regeneration, storage transactions, recovery, and mutation facts.\nControllers and widgets must not call the vxcore transfer API directly. Full orchestration contract:\n[src/core/services/AGENTS.md § Cross-Notebook Node Transfer](src/core/services/AGENTS.md#cross-notebook-node-transfer).\n\n---\n\n## Search Threading Contract\n\nContent search in vxcore owns NO thread pool: it enqueues one work item per file-chunk onto the\n`\"vxcore.search\"` work queue, and the CALLER owns the drain policy (VNote's `SearchService` runs\nthe drain pool; the initiating thread help-drains, which is the single-threaded correctness\nfloor).\n\nFull contract: [src/core/services/AGENTS.md § Search Threading Contract](src/core/services/AGENTS.md#search-threading-contract).\n\n---\n\n## Update Check\n\nOrdinary startup and manual checks fetch release metadata and offer the **release page**.\n`UpdateService` and `UpdateInfo` remain check-only: no asset selection, download, verification\nor installation is added to them.\n\n> **An ordinary check never modifies the install directory or downloads release artifacts.**\n> It creates no lease, staging tree or journal, and extracts or executes nothing. Its only\n> writes are the `lastUpdateCheckTime` / `skippedUpdateVersion` config values. Startup must\n> remain usable from read-only installations (`/usr/bin`, Program Files, a read-only DMG;\n> issue #2728), without probing install-directory writability.\n\nThe sole installation exception is an **explicit Windows “Update Now” notification action**\nlaunching the deployed external PowerShell script. Keep “Check Release” first and available;\nthe manual-check dialog is unchanged and has no automatic-install entry point. The action\nmust disclose downloading, closing and reopening VNote; checks never activate it themselves.\n\nThe external updater must authenticate the offered version's signed full-package manifest\nwith the bundled minisign verifier before using it, then verify the archive and payload.\nIt consumes no deltas, never falls back to another source or unsigned artifacts, and requests\nshutdown only after preparation succeeds. A launch-scoped, PID/token-authenticated pipe and\nan explicit accepted close authorize installation; cancellation or pipe EOF does not. Wait\nfor the original process to exit, including save/sync drains, without force-killing it.\n\nPreserve portable `config` and all unowned files by cloning the stopped installation and\noverlaying verified payload files, then swapping sibling directories. Reject path/reparse\nhazards and incompatible collisions; retain a complete recovery backup and restore it if\nthe second rename fails. Protected installations require manual-update guidance, **never\nelevation, ACL changes or dependency installation**. Reopen with the helper's inherited token;\nreport installed-but-not-reopened separately. Two renames are not power-loss atomic.\n\nRepo-wide forbidden patterns (also constrain CI, packaging, controllers and widgets):\n\n- **Never** download, extract, execute or install a release artifact during an ordinary check.\n- **Never** write outside the configuration directory as part of an update check.\n- **Never** read `assets[]` in `UpdateService`; only the explicit external updater selects assets.\n- **Never** give `UpdateService` a `ConfigMgr2` dependency — add policy to the controller.\n- **Never** restore the C++ installer, delta application or a general-purpose quit CLI/protocol.\n\nThe Windows release publishes a signed full-package manifest and may also publish\nunused delta ZIPs. Signing and external-verifier contract: [docs/update-signing.md](docs/update-signing.md).\nController launch/shutdown ownership: [src/controllers/AGENTS.md § Update Check](src/controllers/AGENTS.md#update-check).\nEndpoints, source-scoped redirects and check threading:\n[src/core/services/AGENTS.md § Update Check](src/core/services/AGENTS.md#update-check).\n\n---\n\n## Logging\n\nUse Qt logging macros:\n```cpp\nqDebug() << \"Debug message\";\nqInfo() << \"Info message\";\nqWarning() << \"Warning message\";\nqCritical() << \"Critical error\";\n```\n\n## Code Formatting\n\nThe pre-commit hook automatically formats staged C++ files using clang-format.\n\n**Manual formatting:**\n```bash\nclang-format -i src/core/myfile.cpp\n```\n\n**Excluded from formatting:** `libs/` directory (third-party code)\n\n### Format before staging and committing\n\nAgents MUST format intended C++ changes with the repository configuration and the same formatter\nas the pre-commit hook **before staging**. Review the resulting diff and preserve unrelated edits.\nPrefer explicit `git add -- <paths>` followed by ordinary `git commit` when the index contains only\nintended changes. If other work must remain staged, a path-limited commit (`git commit --only` or\n`git commit <paths>`) must not rely on the hook for further formatting: verify the formatter would\nmake no additional changes first.\n\nPath-limited commits use a temporary index. The hook's `clang-format -i` and `git add` can update\nthe working file and committed snapshot while leaving the normal index with older formatting.\nAfter such a commit, compare HEAD, index and working tree for the committed paths; do not blindly\nclassify `MM` leftovers as user edits and keep restoring them.\n\nClean up only residue proven to come from the agent's workflow: when `git diff HEAD -- <path>` is\nempty and staged/unstaged formatting hunks are exact inverses, `git restore --staged -- <path>`\nresynchronizes the index without changing the working file. An empty HEAD diff alone does not\njustify clearing intentional user staging. Never use blanket reset/restore or stash for cleanup.\n\n---\n\n## Shared JSON Keys (SSOT)\n\nCross-boundary JSON keys (vxcore↔Qt) live in `<vxcore/notebook_json_keys.h>`.\nSee [`libs/vxcore/AGENTS.md` § JSON Conventions](libs/vxcore/AGENTS.md#json-conventions)\nfor the SSOT contract and the `test_json_key_drift` regression gate.\n\n---\n\n## Module Documentation Index\n\nDetailed knowledge for each module lives in its own AGENTS.md.\n\n**Where to write new documentation:** default to the child `AGENTS.md` that owns the code (create\none for the directory if it does not exist yet). The root doc is injected into *every* agent turn,\nso anything added here costs context on turns that will never need it. Add to root only when the\nknowledge is genuinely repo-wide — i.e. it constrains callers who will never load the owning\nmodule's doc (as the MVC rules and the update-install invariant do), or it is a build/setup/style\nrule that applies everywhere. Even then, keep root to a short normative summary plus a link, and\nput the full detail in the module doc.\n\n| Module | File | Read this when |\n|--------|------|----------------|\n| Source overview | [src/AGENTS.md](src/AGENTS.md) | You need the architecture diagram, directory tree, design-decision rationale, or a source-wide Qt pattern (memory, queued metatypes) |\n| Core & Services | [src/core/AGENTS.md](src/core/AGENTS.md) | ServiceLocator, DI, Buffer2, hooks, config, themes, adding a service |\n| Services (deep) | [src/core/services/AGENTS.md](src/core/services/AGENTS.md) | Sync state model, save/search threading, update check, notifications |\n| Controllers | [src/controllers/AGENTS.md](src/controllers/AGENTS.md) | Adding or changing a controller; MVC rules for controllers |\n| Models | [src/models/AGENTS.md](src/models/AGENTS.md) | Qt Model/View data representations |\n| Views | [src/views/AGENTS.md](src/views/AGENTS.md) | View conventions, delegate patterns |\n| Widgets | [src/widgets/AGENTS.md](src/widgets/AGENTS.md) | Widget conventions, ViewArea2 framework, styling, construction pattern |\n| GUI Services | [src/gui/AGENTS.md](src/gui/AGENTS.md) | Theme, ViewWindowFactory, GUI utilities |\n| Utilities | [src/utils/AGENTS.md](src/utils/AGENTS.md) | PathUtils, HtmlUtils, FileUtils2 reference |\n| pdf.js assets | [src/data/extra/web/pdf.js/AGENTS.md](src/data/extra/web/pdf.js/AGENTS.md) | Touching the vendored PDF viewer or its Qt 6.9+ WebEngine requirement |\n| Testing | [tests/AGENTS.md](tests/AGENTS.md) | Agent GUI smoke testing with `--no-single-instance`, test suites, test mode, fixtures, coverage |\n| CI & Packaging | [.github/AGENTS.md](.github/AGENTS.md) | Workflows, SSH runner debugging, `src/Packaging.cmake`, Qt 6 release packages |\n| vxcore (submodule) | [libs/vxcore/AGENTS.md](libs/vxcore/AGENTS.md) | C library: notebook/config/search backend |\n| vxcore Sync | [libs/vxcore/src/sync/AGENTS.md](libs/vxcore/src/sync/AGENTS.md) | Pluggable sync backend interface (ISyncBackend, SyncManager) |\n| vtextedit (submodule) | [libs/vtextedit/AGENTS.md](libs/vtextedit/AGENTS.md) | Qt editor widget library |\n\n<!-- File: src/AGENTS.md (Tokens: ~3664 | Category: root-instruction) -->\n# src/ — Architecture & Source-Wide Patterns\n\nThis file is inherited by every module under `src/`. It holds the full VNote architecture\n(MVC layering, design-decision rationale, directory tree) and the Qt/C++ patterns that apply\nto all source code regardless of module.\n\nThe normative MVC rules table and the repo-wide build/style rules live in the root\n[AGENTS.md](../AGENTS.md).\n\nPackaging and CI rules — including `src/Packaging.cmake` and Qt 6 deployment — live in\n[.github/AGENTS.md](../.github/AGENTS.md). **An edit to `src/Packaging.cmake` does not\nauto-load that file; open it explicitly.**\n\n---\n\n## Architecture\n\nVNote uses a **clean architecture** with **Model-View-Controller (MVC)** pattern and dependency injection for testability and future plugin support.\n\n### Core Principles\n\n1. **MVC Separation** — Models hold data, Views display it, Controllers handle logic\n2. **Dependency Injection** — No singletons; dependencies passed via ServiceLocator\n3. **Service Layer** — Business logic encapsulated in services, accessed via ServiceLocator\n4. **Hook System** — WordPress-style extensibility for plugins\n\n### MVC Architecture (CRITICAL)\n\nVNote strictly follows the MVC pattern. **All new code MUST adhere to this structure.**\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│                     Controllers                             │\n│  (src/controllers/ - Handle user actions, business logic)   │\n│                                                             │\n│  NotebookNodeController, NewNoteController, etc.            │\n└─────────────────────────────────────────────────────────────┘\n        │                                       │\n        │ Manipulates                          │ Emits signals to\n        ▼                                       ▼\n┌───────────────────────┐       ┌──────────────────────────────┐\n│        Models         │       │           Views              │\n│  (src/models/)        │◄──────│  (src/views/)                │\n│                       │       │                              │\n│  NotebookNodeModel    │ Data  │  NotebookNodeView            │\n│  (QAbstractItemModel) │ flows │  (QTreeView subclass)        │\n└───────────────────────┘       └──────────────────────────────┘\n        │                                       │\n        │ Fetches data from                    │ Receives from\n        ▼                                       ▼\n┌─────────────────────────────────────────────────────────────┐\n│                     ServiceLocator                          │\n│  (DI container - NOT a singleton, passed by reference)      │\n│                                                             │\n│  ┌─────────────────┐  ┌─────────────────────┐  ┌───────────────────┐  │\n│  │ConfigCoreService│  │ NotebookCoreService │  │SearchCoreService  │  │\n│  └─────────────────┘  └─────────────────────┘  └───────────────────┘  │\n└─────────────────────────────────────────────────────────────┘\n                              │\n                              ▼\n┌─────────────────────────────────────────────────────────────┐\n│                         vxcore                              │\n│  (C library: notebook/config/search backend in libs/vxcore) │\n└─────────────────────────────────────────────────────────────┘\n```\n\n### MVC Responsibilities\n\n| Layer | Location | Responsibility | Example |\n|-------|----------|----------------|---------|\n| **Model** | `src/models/` | Data representation, Qt Model/View integration | `NotebookNodeModel` exposes node hierarchy via `QAbstractItemModel` |\n| **View** | `src/views/` | Display data, capture user input, emit signals | `NotebookNodeView` renders tree, emits `nodeActivated` signal |\n| **Controller** | `src/controllers/` | Handle actions, orchestrate Model/View, business logic | `NotebookNodeController` handles new/delete/rename operations |\n| **Service** | `src/core/services/` | Domain operations, data access via vxcore | `NotebookCoreService` wraps vxcore C API for notebook CRUD |\n\n### MVC Example: Notebook Node Operations\n\n```cpp\n// Controller handles user action\nvoid NotebookNodeController::newNote(const NodeIdentifier &p_parentId) {\n  // 1. Emit signal to View to show dialog\n  emit newNoteRequested(p_parentId);\n}\n\n// View shows dialog, then calls back to Controller\nvoid NotebookExplorer2::onNewNoteResult(const NodeIdentifier &p_parentId,\n                                        const NodeIdentifier &p_newNodeId) {\n  m_controller->handleNewNoteResult(p_parentId, p_newNodeId);\n}\n\n// Controller updates Model\nvoid NotebookNodeController::handleNewNoteResult(const NodeIdentifier &p_parentId,\n                                                  const NodeIdentifier &p_newNodeId) {\n  // Model reloads from NotebookCoreService\n  m_model->reloadNode(p_parentId);\n}\n```\n\n### MVC Rules (MUST FOLLOW)\n\nThe normative rules table lives at\n[root AGENTS.md § MVC Rules (MUST FOLLOW)](../AGENTS.md#mvc-rules-must-follow) — that is the\nsingle source of truth, and the anchor `controllers/`, `models/`, `views/` and `widgets/` all\nlink to. Do not copy the table here.\n\n### Key Design Decisions\n\n| Decision | Rationale |\n|----------|-----------|\n| ServiceLocator is NOT a singleton | Enables testing with mock services; explicit dependencies |\n| Services wrap vxcore C API | Qt-friendly interface; encapsulates C interop |\n| Controllers are QObject, not QWidget | Testable business logic without GUI dependencies |\n| Widgets receive `ServiceLocator&` | Constructor injection; no global state |\n| Some files carry a `2` suffix (`MainWindow2`, `Buffer2`, …) | Historical artifact of the now-complete migration off the legacy singleton architecture. The pre-migration counterparts have been removed; the suffix is retained on those existing classes only to avoid a churny rename. The migration is finished, so **new code uses the plain, unsuffixed name unless it genuinely conflicts with an existing type** (e.g. a brand-new `EncodingButton` gets no suffix). Never introduce a `3` suffix. |\n| `Buffer2` is a lightweight copyable handle (like `QModelIndex`) | Returned by `BufferService::openBuffer()`, delegates to `BufferCoreService`; NOT a `QObject`, not heap-allocated |\n| `BufferService` privately inherits `BufferCoreService` | Hook-aware wrapper that fires `vnote.file.*` hooks around core operations |\n| `NodeIdentifier` is a standalone value type | Identifies a node by `notebookId` + `relativePath`; used by `Buffer2`, controllers, and views |\n| `ConfigMgr2` is the ONLY way to access typed config | Owns `MainConfig`/`SessionConfig` with properly merged defaults. NEVER construct a throwaway `MainConfig` from raw JSON — use `m_services.get<ConfigMgr2>()` instead. See `src/core/AGENTS.md` for details. |\n| `ConfigMgr2::getFileFromConfigFolder()` for path resolution | Resolves relative config paths (e.g., `\"web/markdown-viewer-template.html\"`) against the app data directory. Do NOT use `ConfigCoreService::getDataPath()` + manual `QDir::filePath()`. |\n| `PathExists()`/`IsDirectory()`/`IsRegularFile()` wrappers | NEVER pass raw `std::string` to `std::filesystem` — use these wrappers or `PathFromUtf8()` for non-ASCII path safety on Windows |\n\n### Key Design Decisions (ViewArea2 Framework)\n\n| Decision | Rationale |\n|----------|-----------|\n| `ViewAreaController` is the orchestrator | Handles open/close/split/move logic, fires hooks, uses WorkspaceCoreService |\n| `ViewArea2` is a pure view | Owns QSplitter tree + ViewSplit2 instances, no business logic |\n| `ViewSplit2` maps 1:1 to vxcore workspace | Each tab widget pane corresponds to one `WorkspaceCoreService` workspace |\n| `ViewWindow2` receives an initial `Buffer2` in its constructor | Opt-in same-type windows can rebind through the guarded replacement lifecycle while retaining their ID/resources; queued work must check buffer generation. See [tab reuse](widgets/AGENTS.md#note-tab-reuse). |\n| `ViewWindowFactory` maps file types to creators | Registry pattern; plugins register creators for new file types |\n| Splitter orientation follows Vim convention | Left/Right split → `Qt::Horizontal`, Up/Down split → `Qt::Vertical` |\n| Session layout stored as JSON in `SessionConfig` | Recursive splitter tree + workspace IDs for full layout persistence |\n| Concrete ViewWindows live alongside the framework | `MarkdownViewWindow2`, `TextViewWindow2`, `PdfViewWindow2`, `MindMapViewWindow2`, and `WidgetViewWindow2` are registered with `ViewWindowFactory` per file type |\n\n### Directory Structure\n\n```\nsrc/\n├── main.cpp                # Entry point with DI wiring\n├── core/\n│   ├── servicelocator.h    # DI container\n│   ├── nodeidentifier.h    # Lightweight node ID (notebookId + relativePath)\n│   ├── services/           # Service layer (wraps vxcore)\n│   │   ├── configcoreservice.h/.cpp\n│   │   ├── notebookcoreservice.h/.cpp\n│   │   ├── searchcoreservice.h/.cpp\n│   │   ├── filetypecoreservice.h/.cpp\n│   │   ├── buffercoreservice.h/.cpp\n│   │   ├── bufferservice.h/.cpp    # Hook-aware wrapper, returns Buffer2\n│   │   ├── buffer2.h/.cpp          # Lightweight buffer handle (like QModelIndex)\n│   │   ├── templateservice.h/.cpp\n│   │   ├── historyservice.h/.cpp   # Aggregate per-notebook history across notebooks\n│   │   ├── workspacecoreservice.h/.cpp  # Workspace operations (split pane ↔ vxcore workspace)\n│   │   └── hookmanager.h/.cpp\n│   ├── hookcontext.h       # Hook callback context\n│   ├── hooknames.h         # Hook name constants\n│   ├── configmgr2.h/.cpp   # High-level config manager using DI\n│   └── iconfigmgr.h        # Interface for config managers\n├── gui/                    # GUI-aware services and utilities\n│   ├── services/\n│   │   ├── themeservice.h/.cpp         # GUI-aware theme management service\n│   │   └── viewwindowfactory.h/.cpp    # Registry mapping file types to ViewWindow2 creators\n│   └── utils/\n│       ├── widgetutils.h/.cpp          # Widget utility helpers\n│       ├── themeutils.h/.cpp           # Theme utility helpers\n│       ├── imageutils.h/.cpp           # Image utility helpers\n│       └── guiutils.h/.cpp             # General GUI utilities\n├── models/                 # Qt Model/View models\n│   ├── notebooknodemodel.h/.cpp       # QAbstractItemModel for node hierarchy\n│   └── notebooknodeproxymodel.h/.cpp  # Proxy model for sorting/filtering\n├── views/                  # Qt views and delegates\n│   ├── notebooknodeview.h/.cpp        # QTreeView for nodes\n│   ├── notebooknodedelegate.h/.cpp    # Item delegate for node rendering\n│   ├── combinednodeexplorer.h/.cpp    # Composite MVC wiring widget\n│   └── filenodedelegate.h/.cpp        # Item delegate for file list\n├── controllers/            # Controllers (business logic mediators)\n│   ├── notebooknodecontroller.h/.cpp  # Node operations controller\n│   ├── newnotecontroller.h/.cpp       # New note dialog controller\n│   ├── newfoldercontroller.h/.cpp     # New folder dialog controller\n│   ├── newnotebookcontroller.h/.cpp   # New notebook dialog controller\n│   ├── opennotebookcontroller.h/.cpp  # Open notebook flow controller\n│   ├── managenotebookscontroller.h/.cpp\n│   ├── importfoldercontroller.h/.cpp\n│   ├── recyclebincontroller.h/.cpp\n│   └── viewareacontroller.h/.cpp      # View area orchestrator (open/close/split/move)\n├── widgets/                # UI widgets (views receiving ServiceLocator&)\n│   ├── mainwindow2.h/.cpp  # Main window shell\n│   ├── notebookexplorer2.h/.cpp\n│   ├── notebookselector2.h/.cpp\n│   ├── toolbarhelper2.h/.cpp\n│   ├── viewwindow2.h/.cpp  # Abstract base for file viewer windows\n│   ├── markdownviewwindow2.h/.cpp  # Markdown editor/preview window\n│   ├── textviewwindow2.h/.cpp      # Plain text editor window\n│   ├── pdfviewwindow2.h/.cpp       # PDF viewer window\n│   ├── mindmapviewwindow2.h/.cpp   # Mind map viewer window\n│   ├── widgetviewwindow2.h/.cpp    # Generic widget-hosting window\n│   ├── viewsplit2.h/.cpp   # QTabWidget-based split pane (one vxcore workspace)\n│   ├── viewarea2.h/.cpp    # Splitter tree view (manages ViewSplit2 layout)\n│   └── dialogs/            # Dialog widgets\n│       ├── newnotedialog2.h/.cpp\n│       ├── newfolderdialog2.h/.cpp\n│       ├── newnotebookdialog2.h/.cpp\n│       ├── managenotebooksdialog2.h/.cpp\n│       └── importfolderdialog2.h/.cpp\n├── net/\n│   └── networkutils.h/.cpp # core_net: Qt Core/Network-only HTTP helpers\n│                           # (vnotex::NetworkUtils / NetworkReply / NetworkAccess)\n├── utils/\n│   └── fileutils2.h/.cpp   # File utilities\n└── ...\n```\n\n---\n\n## Source-Wide Qt Patterns\n\n### Memory Management\n```cpp\n// Use Qt smart pointers\nQScopedPointer<MainConfig> m_config;\nQSharedPointer<Task> task;\n\n// QObject parent-child for automatic cleanup\nm_themeMgr = new ThemeMgr(this);  // 'this' takes ownership\n```\n\n### Queued-Connection Metatypes on Qt 6\n\nQueued calls need copyable argument types known to Qt's metatype system. Keep\n`Q_DECLARE_METATYPE` and the existing typed `qRegisterMetaType<T>()` registrations for\ncustom values crossing threads. Qt 6's moc-generated signal metadata supplies parameter\ntypes directly; an unqualified signal spelling alone does **not** require an extra\n`qRegisterMetaType<T>(\"T\")` alias.\n\nString-based APIs still need their exact runtime type names. In particular, the existing\n`QMetaObject::invokeMethod(worker, \"method\", Qt::QueuedConnection, Q_ARG(T, value))` form\nmust resolve the spelling stringified by `Q_ARG`. If `Q_DECLARE_METATYPE(vnotex::T)`\nregisters the qualified name but `Q_ARG` spells `T`, retain the unqualified alias as well.\n\n`ImageHostService` registers `\"ImageHostWorkItem\"` for its `doUpload`, `doRemove` and\n`doTestConfig` worker dispatches. Keep that registration; real worker delivery is\ncovered by `tests/core/test_imagehostasync.cpp`.\nThe typed `SearchWorker` result/error and `ImageHostWorker` completion connections need\nno extra unqualified aliases. Real queued search delivery is covered by\n`testSimpleSearchUnicodeReplacement` and `testSearchContentStreamingBatchUnion` in\n`tests/core/test_searchservice.cpp`.\n\nDo not add aliases to every `Q_DECLARE_METATYPE` indiscriminately: inspect the actual\nstring-based callers, if any, before adding or removing a named registration.\n\n---\n\n## Related Modules\n\n| Module | File | Read this when |\n|--------|------|----------------|\n| Core & Services | [core/AGENTS.md](core/AGENTS.md) | ServiceLocator, DI, Buffer2, hooks, config, adding a service |\n| Services (deep) | [core/services/AGENTS.md](core/services/AGENTS.md) | Sync, save/search threading, update check, service internals |\n| Controllers | [controllers/AGENTS.md](controllers/AGENTS.md) | Adding or changing a controller |\n| Models | [models/AGENTS.md](models/AGENTS.md) | Qt Model/View data representations |\n| Views | [views/AGENTS.md](views/AGENTS.md) | View conventions, delegate patterns |\n| Widgets | [widgets/AGENTS.md](widgets/AGENTS.md) | Widget conventions, ViewArea2 framework, styling |\n| GUI Services | [gui/AGENTS.md](gui/AGENTS.md) | Theme, ViewWindowFactory, GUI utilities |\n| Utilities | [utils/AGENTS.md](utils/AGENTS.md) | PathUtils, HtmlUtils, FileUtils2 |\n| CI & Packaging | [../.github/AGENTS.md](../.github/AGENTS.md) | `src/Packaging.cmake`, Qt 6 deployment |\n\n<!-- File: tests/AGENTS.md (Tokens: ~9194 | Category: root-instruction) -->\n# Agent Guidelines for tests\n\nTest infrastructure for VNote using the QtTest framework. Parent VNote Qt tests live under\n`tests/` and are built via CMake's `add_qt_test()` helper; vxcore has a **separate** suite under\n`libs/vxcore/tests/` with its own build dir and `add_vxcore_test()` helper. Tests exercise\nservices, models, controllers, and utilities against the vxcore C backend in isolated test mode.\n\n## Two test locations — pick the right one\n\nVNote has tests in **two separate locations** with different registration helpers and build\nconfigurations. Pick the right one or your test will silently never run.\n\n### Parent VNote tests (`tests/`)\n\nBuilt with the parent project into `build-debug/tests/<category>/` **when `-DVNOTE_BUILD_TESTS=ON`\nis set** (it defaults to OFF; CI turns it ON). Registered via\nthe `add_qt_test()` helper (details in [Adding New Tests](#adding-new-tests) below). Categories:\n`controllers/`, `core/`, `gui/`, `integration/`, `models/`, `utils/`, `widgets/`.\n\n`VNOTE_BUILD_TESTS` also controls the bundled vtextedit test targets. VNote sets\n`VTEXTEDIT_BUILD_TESTS` from that flag; standalone vtextedit keeps its own default ON.\n\nConfigure the test-enabled build dir from repo root (also re-run this if `tests/CMakeLists.txt`\nitself changed): `cmake -B build-debug -DVNOTE_BUILD_TESTS=ON`.\n\n**Build + run:**\n```powershell\ncmake --build build-debug --config Debug --target test_yourthing\nctest --test-dir build-debug -C Debug -R \"^test_yourthing$\" --output-on-failure\n```\n\n### vxcore submodule tests (`libs/vxcore/tests/`)\n\n**Separate build dir required** — the parent `build-debug/` does NOT compile vxcore tests. Use a\ndedicated build dir configured with `-DVXCORE_BUILD_TESTS=ON`. Each test is a standalone\nexecutable (file basename = test target name) registered via `add_vxcore_test()`.\n\n**Add a new test:**\n1. Create `libs/vxcore/tests/test_yourthing.cpp` with its own `main()`. Subtests are functions that return `int` (0 = pass); call them via `RUN_TEST(...)` from `test_utils.h`. Always start `main()` with `vxcore_set_test_mode(1)` so the test uses `%TEMP%\\vxcore_test*` instead of real AppData.\n2. Register in `libs/vxcore/tests/CMakeLists.txt`:\n   ```cmake\n   add_vxcore_test(test_yourthing)\n   ```\n   The helper auto-links `vxcore` and adds `${CMAKE_SOURCE_DIR}/src` + `third_party` include dirs.\n3. Test executables CANNOT call vxcore-internal symbols that lack `VXCORE_API` — they live outside the DLL. If you need an internal helper (e.g., `GetCurrentTimestampMillis()`), re-derive it locally in the test file rather than exporting it.\n\n**Configure (once per machine, or after CMake version upgrade):**\n```powershell\ncmake -S libs/vxcore -B libs/vxcore/build_test -G \"Visual Studio 17 2022\" -A x64 -DVXCORE_BUILD_TESTS=ON\n```\n\n**Build + run:**\n```powershell\ncmake --build libs/vxcore/build_test --config Debug --target test_yourthing\nctest --test-dir libs/vxcore/build_test -C Debug -R \"^test_yourthing$\" --output-on-failure\n```\n\n### Universal rules\n\n- **Anchor the ctest regex** with `^...$` to avoid false matches (e.g., `-R \"^test_sync$\"` won't sweep in `test_session_persistence`).\n- After a CMake version upgrade, stale build dirs fail to reconfigure with errors like `CMakeSystem.cmake.in does not exist`. Delete the offending build dir and re-run the configure command from scratch — do NOT try to repair in place.\n- After modifying a touched module, run the FULL test target for that module (not just your new subtest) to catch regressions; vxcore tests print each subtest name to stdout so you can verify the new one ran.\n\nChild-process readiness is a byte-stream protocol: accumulate stdout until the complete\nmarker arrives under one deadline. IPC slow-start fixtures must use blocking reads rather\nthan pumping the primary event loop while waiting for that marker.\n## Test Structure\n\n```\ntests/\n├── CMakeLists.txt          # Root test config with add_qt_test() helper\n├── helpers/\n│   ├── CMakeLists.txt\n│   ├── test_helper.h       # Common includes\n│   └── temp_dir_fixture.h  # QTemporaryDir wrapper (+ copyFrom() for fixtures)\n├── data/                   # On-disk test fixtures (see \"Test Data Fixtures\" below)\n│   └── vnote3_notebooks/\n│       └── database_notebook/  # Real VNote3 notebook with 17 files + subfolder\n├── core/\n│   ├── CMakeLists.txt\n│   ├── test_error.cpp\n│   ├── test_exception.cpp\n│   ├── test_configservice.cpp\n│   ├── test_notebookservice.cpp\n│   ├── test_bufferservice.cpp   # BufferCoreService tests\n│   ├── test_buffer.cpp          # Buffer2 + BufferService integration tests (29 cases)\n│   └── test_vnote3migrationservice.cpp  # VNote3 migration tests (46 cases)\n└── utils/\n    ├── CMakeLists.txt\n    ├── test_pathutils.cpp\n    └── test_htmlutils.cpp\n```\n\n## CRITICAL: Test Mode for vxcore\n\n**Always enable test mode BEFORE creating vxcore context in tests:**\n\n```cpp\nvoid TestMyService::initTestCase() {\n  // CRITICAL: Must call BEFORE vxcore_context_create()\n  // Prevents tests from corrupting real user data\n  vxcore_set_test_mode(1);\n\n  m_ctx = vxcore_context_create();\n  // ...\n}\n```\n\n**Why this matters:**\n- Without test mode, vxcore uses real `AppData/Local` paths\n- Tests will corrupt actual user configuration\n- Test mode redirects to isolated temp directories\n\nEncryption fixtures created by `prepare_notebook` / `commit_notebook` are self-contained v2\nnotes. Reopen them with `vxcore_encryption_unlock_note`, using the notebook-relative path\ncaptured before closing the buffer; `unlock_notebook` is only for explicit legacy-vault tests.\n\n## Writing Service Tests\n\n```cpp\n// tests/core/test_myservice.cpp\n#include <QtTest>\n\n#include <vxcore/vxcore.h>\n\n#include <core/myservice.h>\n\nnamespace tests {\n\nclass TestMyService : public QObject {\n  Q_OBJECT\n\nprivate slots:\n  void initTestCase();\n  void cleanupTestCase();\n  void testBasicOperation();\n\nprivate:\n  vxcore_context *m_ctx = nullptr;\n};\n\nvoid TestMyService::initTestCase() {\n  vxcore_set_test_mode(1);  // CRITICAL!\n  m_ctx = vxcore_context_create();\n}\n\nvoid TestMyService::cleanupTestCase() {\n  vxcore_context_destroy(m_ctx);\n  m_ctx = nullptr;\n}\n\nvoid TestMyService::testBasicOperation() {\n  vnotex::MyService service(m_ctx);\n  QVERIFY(!service.doSomething().isEmpty());\n}\n\n} // namespace tests\n\nQTEST_GUILESS_MAIN(tests::TestMyService)\n#include \"test_myservice.moc\"\n```\n\n## Enable Tests\nEnable parent and bundled editor tests explicitly at configure time; do not edit CMake files:\n```bash\ncmake -S . -B build -DVNOTE_BUILD_TESTS=ON\n```\n\nCMake retains cached choices. An existing ON build stays ON even though the default is OFF;\nconfigure with `-DVNOTE_BUILD_TESTS=OFF` to remove both sets of test targets from that build.\n\n## Build Tests\n```bash\ncmake --build build --config Release --target test_error test_exception test_pathutils test_htmlutils\n```\n\n### Test Libraries\n\n| Library | Purpose | Links |\n|---------|---------|-------|\n| `core_services` | Service layer (ConfigCoreService, NotebookCoreService, etc.) | Qt6::Core, Qt6::Gui, Qt6::Network, Qt6::Concurrent, vxcore, core_net |\n| `core_net` | Qt-Core/Network-only HTTP helpers (`vnotex::NetworkAccess`) | Qt6::Core, Qt6::Network |\n| `core_configs` | Config classes (ConfigMgr2, MainConfig, SessionConfig, etc.) | core_services, VTextEdit |\n\n`core_services` links **neither `Qt::Widgets` nor `VTextEdit`** — see [`../src/core/services/AGENTS.md`](../src/core/services/AGENTS.md#core_services-is-qt-widgets-free-and-vtextedit-free-contract). Practical consequence for tests: a target that needs QtWidgets or vtextedit headers must say so in its own `LINKS`; nothing arrives transitively through `core_services` any more.\n\n## Run Tests\n\n**Windows (Qt DLLs must be in PATH):**\n```powershell\n$env:PATH = \"C:/Qt/6.9.3/msvc2022_64/bin;\" + $env:PATH\n./build/tests/core/test_error.exe\n./build/tests/core/test_configservice.exe\n./build/tests/core/test_notebookservice.exe\n```\n\n**Using CTest (requires Qt in system PATH):**\n```bash\nctest --test-dir build                    # Run all tests\nctest --test-dir build -R test_error      # Run single test (pattern match)\nctest --test-dir build --output-on-failure  # Show output on failure\n```\n\n**macOS keychain tests:** core tests that exercise real QtKeychain jobs use\n`VNOTE_KEYCHAIN_TEST_MAIN` from `test_helper.h`: a native GUI application on macOS,\n`QCoreApplication` elsewhere. On macOS, run these and keychain-using widget tests with\n`QT_QPA_PLATFORM=cocoa`, not `offscreen`: QtKeychain completes on the native main queue,\nwhich the offscreen UNIX dispatcher does not drain. Do not set\n`QT_EVENT_DISPATCHER_CORE_FOUNDATION`: it also replaces worker-thread dispatchers and\ncan stall queued work. Tests also need an unlocked default keychain; macOS CI creates\na disposable one after compilation, immediately before CTest, so the unlock lease covers\nexecution rather than the cold build. It restores the previous default after testing.\n\nFor asynchronous modal-error tests, wait for controller completion with `QSignalSpy::wait`.\nKeep the dismissal timer active until completion and restrict it to the tested dialog's\nmessage box. Do not stop on the first click or infer dialog readiness from\n`QThread::loopLevel()`.\n\n`ConsentDialogDriver` shares the native resource/preview consent fixture. It owns a\nparent-matched `QPointer<QMessageBox>` and an answer timer through dispatch and destruction\nof the completed message box, not just until the context menu or click returns. A Cocoa native alert's QWidget is a hidden\nmirror (`WA_DontShowOnScreen`), not its interactive surface: click the real NSButton's\nYes/No/YesToAll tag on the active native alert, or the parent's attached sheet for WindowModal.\nNever re-show or click that dummy QWidget during native modal startup. The QWidget fallback\nrequires the matching visible active modal and its visible expected button. Cocoa uses\nan `NSTimer` in common/modal/tracking run-loop modes: a native alert nested inside manual\nQt event dispatch can suppress QTimer activation. Invalidate before answering and on scope\nexit; the same original eight-second deadline covers readiness and completion. Native Escape\nuses the alert's key-equivalent path; Close uses `QMessageBox::close()` to end its native\nmodal helper as well. Neither may be replaced with a No-button click.\n\n**Native WebEngine raster tests:** on macOS, `test_mindmapexport`, `test_htmlpdfexporter`,\n`test_markdownwebjobs` and `test_webviewservice` use `cocoa` and the matching Qt kit's\nplatform plugin, not `offscreen`, so hidden views have native graphics contexts.\nKeep deployed-app WebEngine overrides cleared and non-macOS environments unchanged.\n\nNative Markdown render waits observe the adapter's completed document generation and\nactual DOM, not the independent web-job handshake. JavaScript observations pump events;\nuse one `QTest::qWaitFor` and recheck the generation after observation, rather than a\n`QTRY` predicate that can become false again during its post-success evaluation.\nIf the next assertion requires native currentness, use `waitForCurrentRender`, not the\nDOM-only `waitForRender`; protected/public handoffs retain all token and stale-work checks.\n\nTypst context-menu fixtures select Paste by Qt's `edit-paste` action ID and retire\nresources after action admission, not from a timer that can fire during Cocoa's menu\nflash. Empty clipboard MIME data is nullable. Native drag fixtures must release input\nwithout depending on GUI timers: Windows OLE and Cocoa tracking loops can starve them.\nUse joined native-input workers, restoring the cursor and preventing late input from\nreaching the next case; Cocoa retains app-local NSEvents rather than global HID input.\nWindows drag motion uses non-coalesced `SendInput` records in normalized virtual-desktop\ncoordinates. After selecting text, acquire foreground, repaint, deliver posted `LayoutRequest`\nevents, repaint the resolved gutter layout and complete `DwmFlush` before measuring positions.\nAfter source-pointer receipt, recheck mapped start/end and `widgetAt` before pressing: a\nfirst-paint gutter update must not move the viewport underneath cached coordinates.\nFailure diagnostics record native move/press/activation messages and GUI-thread state.\nQuery `GetGUIThreadInfo(guiThreadId)` from the worker, not its own GetFocus/GetCapture;\nnever consume or reroute an event just to make the fixture pass.\nWindows workers acknowledge the press and threshold, then post a registered native message to\nthe source HWND. The GUI filter acknowledges it only while an owned `QDrag` exists. Send one\ndistinct bootstrap motion, await capture transfer from the fixture to a GUI-thread window,\nthen send the final destination; an already-posted capture never skips the bootstrap motion.\nAll waits share the original deadline. Qt timers or waiting for DragEnter before startup motion\ncan deadlock in Qt's filtered native pump. Destination arrival can be DragEnter or DragMove.\nRelease follows arrival or the bounded deadline; joined workers always attempt release and\nrestore the saved pointer. Cocoa\ncreates timestamped NSEvents at dispatch, without warping ahead of delivered source events.\nCreate native peers before measuring drag coordinates. On Cocoa, find the source viewport's\nnearest already-native QWidget ancestor without creating a peer. Its flipped QNSView coordinates\nmatch Qt's window coordinates: map locally to that ancestor, then use `convertPoint:toView:nil`\nfor the event and `convertRectToScreen:` plus the primary-screen Y flip for the Quartz pointer.\nDo not mix Qt's cached global frame origin into native input or compensate with titlebar offsets.\nTypst shortcut/focus tests use xcb under Xvfb on Linux, not offscreen. Keep the normal\nQtTest crash handler. Build the host Typst module's `test_typstviewwindow_insertion` executable\nonce, then select `^test_typstviewwindow_(insertion|cleanup|resources)$` in CTest. These serial\ngroups share the executable and each retain a 240-second deadline. With `VNOTE_TYPST_TEST_GROUP`\nunset, a direct executable launch runs all default-enabled cases. Each group writes plaintext stdout and\nits own `test_typstviewwindow_<group>.xml` JUnit file in the CTest working directory. CI\npreserves original parent/native CTest logs and these reports before diagnostic reruns,\nsince hosted stdout can omit assertions.\n\nThe controller executable is also partitioned without extending deadlines. Build\n`test_typstviewwindowcontroller` once and run\n`^test_typstviewwindowcontroller(_images|_resources)?$`: the base registration covers\nnavigation, followed by image validation/storage and resource acquisition/viewing.\nAll three are serial, retain 60-second deadlines and `-nocrashhandler`, and use the\nsame native runtime environment. Empty/unset `VNOTE_TYPST_CONTROLLER_TEST_GROUP` runs\nall cases directly; unknown groups fail. Slot boundaries `imageStorage()` and\n`resourceSourceRejection()` keep every test in exactly one group.\n\nThe `resourceSnapshotBytes(retained-gif-mime-not-replaced-file)` row is disabled by default.\nSet `VNOTE_TEST_RETAINED_GIF_SNAPSHOT=1` to register it; only the literal `1` enables it.\nThe PNG snapshot row and every other resource test remain enabled. Windows Clipboard\nUser Service can interfere with the GIF row on a shared desktop; the opt-in does not\nchange its assertions or convert failures into skips.\n\nAll three parent CI workflows set this flag, including for diagnostic reruns. **Pre-push\nverification must also set it when this suite is affected**; a default local pass is not\nproof of the full CI/pre-push coverage. No build reconfiguration is needed:\n\n```powershell\n$env:VNOTE_TEST_RETAINED_GIF_SNAPSHOT = \"1\"\nctest --test-dir build-debug -C Debug -R '^test_typstviewwindow_resources$' --output-on-failure\n```\n\n```bash\nVNOTE_TEST_RETAINED_GIF_SNAPSHOT=1 ctest --test-dir build-debug -R '^test_typstviewwindow_resources$' --output-on-failure\n```\n\nSet Qt's active window explicitly at focus-sensitive fixture operations; `activateWindow()`\nalone depends on a window manager under Xvfb. After dismissing a modal dialog, wait for\nwindow activation and editor focus before sending shortcuts. Settle native exposure and\nactivation before measuring drag coordinates. Before inserting tab-replacement or presentation\ndrafts programmatically, wait for editor focus: that registers the active writer used by Save.\nThe lazy-view `showWindow()` helper establishes those exposure/activation/focus preconditions;\ndo not require editor focus for hidden/disabled editors or encrypted buffers already locking.\nNative popup fixtures must settle the owner's activation before promotion/opening. A\ntransient parent shares `QWindow::isActive()`; when exact ownership matters, wait for both\n`QGuiApplication::focusWindow()` and `QApplication::activeWindow()` to match the intended host.\n`activateNativeHost()` observes the target's closest existing native surface, not only the\nroot window: fullscreen native children can cover the root's entire exposed region. Retain\ncurrent host/surface ancestry and exact native activation checks, without creating peers or\nforcing the child focus that a test is meant to observe. After modal cancellation, restore\nthe fixture's native top-level activation if needed, then observe automatic child-focus\nrestoration; never call `setFocus()` to supply the child focus under test. Native dialog\ninput similarly waits for `QGuiApplication::focusObject()`, not just QWidget `hasFocus()`.\nContext-menu fixtures wait for both selection and deselection to reach WebEngine before clicking.\nUse an explicit blank non-text region for an unselected context: Cocoa/Chromium can select\nthe word under a right-click even after the fixture cleared selection. Keep selected-link,\nimage and empty-context reset assertions distinct.\n\n`test_viewarea_tab_reuse` retains only its unmodified, version-stamped bundled asset directories\nbetween QtTest rows. It removes all local state and all non-bundle app entries before creating\na fresh context, config, notebook, services and WebEngine profile. Obtain cleanup paths from\nConfigMgr2, and prune only in the test class `init()`, never inside `Fixture::initialize()`:\nintra-row fixture recreation intentionally tests persisted sessions. This suite must not edit\ninstalled assets, including `web/css/user.css` or user-created templates; asset-customization\nfixtures must retain the full test-directory reset. Keep the existing suite deadlines.\n\nPDF.js text-item boundaries are not word boundaries: ligatures can split a word across\nitems. Reconstruct text by concatenating `item.str` and adding a newline only for\n`item.hasEOL`, as the bundled viewer does; do not insert spaces between every item.\n\n### Local pre-push flaky-test whitelist\n\nOn a **shared interactive Windows desktop only**, the following native-input failures\nmay be treated as non-gating for a local push. This is a closed list of failure sites,\nnot permission to skip an executable or any product-behavior assertion.\n\n| Executable | Exact QtTest case / row | Eligible failure site |\n|---|---|---|\n| `test_viewarea_tab_reuse` | `previewReaderNavigationAndNativeHost` | First source-reader `B` link's `clickNativeSurface` hit-test precondition |\n| `test_viewarea_tab_reuse` | `previewOpenInNewTabs(main-source)` | Open Link button's `clickNativeSurface` hit-test precondition |\n| `test_viewarea_tab_reuse` | `previewOpenInNewTabsDuplicateConsent(other-tab-save)` | Incoming editor's `hasFocus()` precondition before consent/promotion |\n\n**All** of these conditions are required for each use:\n\n1. Build the affected targets and attempt the **full executable** at the revision being\n   pushed. Preserve the first failure report. Windows GUI executables may omit QtTest\n   assertions from CTest's captured stdout; use explicit QtTest `-o <path>,txt` and\n   `-o <path>,junitxml` outputs for diagnosis with the registered runtime environment.\n2. Match both the exact row and failure site above. For a click failure, diagnostics must\n   show a visible, in-bounds target, an inactive native window, and null/foreign\n   `QApplication::widgetAt()` at the click position. For the editor-focus failure, also\n   establish that the native window was inactive. A bare `clickNativeSurface`/`hasFocus`\n   failure or a passing retry alone is **not** enough to classify the failure.\n3. The affected case must pass in isolation at the same revision, and all remaining rows\n   and other affected suites must run and pass. Prefer an exclusive desktop run when\n   available. Do not retry unchanged runs merely until one passes; reruns must investigate\n   a cause or follow an environment correction.\n4. The unpushed changes must not modify the affected native preview input, focus, geometry,\n   promotion, hit-test helpers, fixture activation or relevant platform configuration.\n   Those changes require a clean native verification run or a separate explicit user exception.\n5. Report the exact case, native-state evidence and local waived gate as a **skip**, not\n   a pass. Any other failure, including later content, routing, save/discard/cancel,\n   persistence assertions, crashes or timeouts, remains blocking even in a listed case.\n\nCI and packaging gates are never waived. Do not add `QSKIP`, exclude the executable or\nrows from CTest, weaken assertions, or change deadlines to implement this policy.\nReassess/remove an entry when its native activation cause is fixed.\n\nThe 2026-10-29 investigation observed a visible/in-bounds preview button with an inactive\nnative window and null `widgetAt`, while failing rows varied across runs; the unmodified\nfull suite subsequently passed all 73 cases. That earlier fixture requested activation\nwithout waiting for native activation; the current fixture observes its owned native surface.\nThe original reader-click rejection and editor-focus\nfailure did not identify a covering process or independently prove their native-window\nstate, so those assertion messages alone do not qualify for the exception.\n\n### VTextEdit.dll runtime copy\n\nTests that link `VTextEdit` (directly, or transitively via `core_configs`) load `VTextEdit.dll` at runtime. **`core_services` alone no longer pulls it in**, so a pure-core test that links only `core_services` + `vxcore` runs with no `VTextEdit.dll` anywhere near it. The build copies the DLL next to each subdirectory's test exes via a `POST_BUILD` step anchored on one test target per subdir (the canonical reference is `tests/utils/CMakeLists.txt:78-83`):\n\n```cmake\n# Copy VTextEdit DLL next to test executable so CTest can find it at runtime.\nadd_custom_command(TARGET test_clipboard_image POST_BUILD\n  COMMAND ${CMAKE_COMMAND} -E copy_if_different\n    $<TARGET_FILE:VTextEdit>\n    $<TARGET_FILE_DIR:test_clipboard_image>\n)\n```\n\nWith Typst enabled, `VTextEdit.dll` directly depends on `vtextedit_typst.dll`. The common\n`add_qt_test()` supplies Windows CTest lookup paths for Qt, VTextEdit and the native backend\nwithout linking pure-core tests to them. Tests replacing ENVIRONMENT must restore PATH\nafterward; append it separately to each test instead of copying an escaped PATH list.\n\n**When adding tests to a NEW subdirectory under `tests/` whose targets link `VTextEdit`**, that subdir's `CMakeLists.txt` MUST include this `POST_BUILD` block anchored on any one such test target. Without it, `ctest` reports the test as failed with exit code `0xc0000135` (Windows \"DLL not found\") and the test binary never reaches its `main()`. One copy per subdir is enough; `copy_if_different` makes incremental rebuilds free.\n\n### Running execs that depend on VTextEdit.dll (CRITICAL — avoid false-positive smoke tests)\n\n`build-debug/src/vnote.exe` and widget/editor tests load `VTextEdit.dll` and its native\n`vtextedit_typst.dll` dependency, plus the matching Qt 6 runtime. The editor/runtime DLLs\nare staged in `build-debug/bin` and beside VNote; Typst test targets also refresh their\nlocal copies. Pure-core tests linked only to `core_services` + `vxcore` remain independent\nof VTextEdit. Direct test launches and disposable app copies still need the configured\nQt/runtime lookup paths. A missing DLL can show a loader modal before `WinMain`.\n\n**This is a verification trap**: when the loader dialog blocks the process, the OS reports the process as alive (it has a PID, is technically running), so naive checks like `Start-Process … -PassThru` + `Sleep` + `HasExited` return `$false` → you falsely conclude the binary started. The process is actually frozen in pre-WinMain limbo waiting for someone to click the dialog. `Stop-Process -Force` afterwards silently dismisses the dialog and the lie is preserved.\n\n### Agent GUI smoke testing (Windows)\n\nUse `--no-single-instance` to test a separate VNote process without closing the user's running\ninstance. **It skips locking and IPC, not data isolation.** Never launch the agent's GUI smoke test\nagainst the normal profile or real notebooks. Create a fresh portable copy for each run; its\nexisting `config/` folder redirects config, session and WebEngine data to that copy.\n\nBefore using this recipe, check the OS Documents folder's `my_notebook` path. If it is missing or\nan empty directory, first-run setup can create/open a bundled notebook there, **outside the portable\ncopy**; use a disposable OS account instead. Do not edit, empty, or delete the user's directory to\nsatisfy the collision guard. Continue testing only the scratch file in the disposable copy.\n\nRun from the repository root after building `vnote`. Set `$build` to the build you just verified;\nthe example derives the matching Qt runtime from its CMake cache, rather than mixing Qt versions.\nRun both blocks in the same persistent PowerShell terminal, keeping it open during GUI inspection\nand cleanup; avoid a one-shot wrapper that captures inherited process pipes.\n\n```powershell\n$ErrorActionPreference = \"Stop\"\n$build = \"build-debug\" # Replace with the existing build you just compiled\n$qtDir = (Select-String -Path \"$build/CMakeCache.txt\" -Pattern '^Qt6_DIR:PATH=' |\n          Where-Object { $_.Line -notmatch 'NOTFOUND$' } | Select-Object -First 1).Line\nif (-not $qtDir) { throw \"No configured Qt directory in $build/CMakeCache.txt\" }\n$qtBin = [IO.Path]::GetFullPath((Join-Path ($qtDir -split '=', 2)[1] \"../../../bin\"))\n$env:PATH = \"$qtBin;\" + (Resolve-Path \"$build/bin\").Path + \";\" + $env:PATH\n$env:QT_QPA_PLATFORM_PLUGIN_PATH = Join-Path $qtBin \"../plugins/platforms\"\n$env:QTWEBENGINEPROCESS_PATH = Join-Path $qtBin \"QtWebEngineProcess.exe\"\n\n$smoke = Join-Path $build (\"agent-smoke-\" + [guid]::NewGuid().ToString(\"N\"))\nNew-Item -ItemType Directory -Path \"$smoke/config\" | Out-Null\nCopy-Item \"$build/src/vnote.exe\", \"$build/src/vnote_extra.rcc\", \"$build/src/qt.conf\" $smoke\nif (Test-Path \"$build/src/translations\") {\n  Copy-Item \"$build/src/translations\" \"$smoke/translations\" -Recurse\n}\nSet-Content -Path \"$smoke/smoke.txt\" -Value \"Disposable agent smoke-test note\" -Encoding UTF8\n$proc = Start-Process -FilePath \"$smoke/vnote.exe\" -PassThru `\n  -ArgumentList @(\"--no-single-instance\", \"--log-stderr\", \"`\"$smoke/smoke.txt`\"\") `\n  -RedirectStandardError \"$smoke/stderr.log\" `\n  -RedirectStandardOutput \"$smoke/stdout.log\"\nStart-Sleep -Seconds 5\n$proc.Refresh()\nif ($proc.HasExited) { throw \"VNote exited with $($proc.ExitCode); inspect $smoke/stderr.log\" }\nforeach ($module in @(\"VTextEdit.dll\", \"vtextedit_typst.dll\")) {\n  if (-not ($proc.Modules | Where-Object { $_.ModuleName -ieq $module })) {\n    throw \"$module not loaded: check runtime DLLs, not just process liveness\"\n  }\n}\nWrite-Output \"Inspect PID $($proc.Id): its own window must display smoke.txt and the note text\"\n```\n\nThe runtime check is only a prerequisite: inspect the **actual window and requested file**, and\nexercise the feature under test. The main window title can remain `VNote`; do not require the file\nname in that title. Startup/file-open diagnostics are in `$smoke/stderr.log`. Add `--detached-view`\nwhen testing detached windows. Use only scratch notes/notebooks and no production sync credentials.\n\nQuit through VNote's UI (answer **No** if asked to minimize to the system tray), allow save/sync\nworkers to drain, then clean up only this run's copy:\n\n```powershell\n$proc.WaitForExit()\nRemove-Item -LiteralPath $smoke -Recurse -Force\n```\n\nDo not reuse the generated profile for the next run: loading its saved session also performs\nnormal Windows launch-on-login reconciliation. Do not enable launch-on-login in a disposable copy.\nIn-app restart drops the flag and uses the normal guard; start another fresh copy explicitly instead.\nChanging `TEMP`/`TMP` does not isolate the fixed IPC name. For guard/forwarding tests use the unique\nserver/lock overrides in `test_singleinstanceguard`, never the user's production pipe or lock.\nBehavior contract: [Agent / manual testing bypass](../src/core/AGENTS.md#agent--manual-testing-bypass).\n\nThe same PATH prepend is required before running parent `test_*.exe` directly. `ctest --test-dir build-debug` inherits the caller's PATH, so set it once at the start of the session.\n\nvxcore submodule tests (`libs/vxcore/build_test/bin/Debug/test_*.exe`) do NOT depend on Qt or VTextEdit and need no PATH setup.\n\n## Adding New Tests\nUse the `add_qt_test()` helper function in CMakeLists.txt:\n\n```cmake\n# tests/module/CMakeLists.txt\nadd_qt_test(test_myclass\n  SOURCES\n    test_myclass.cpp\n    ${CMAKE_SOURCE_DIR}/src/module/myclass.cpp\n  LINKS\n    Qt6::Gui  # Optional: extra Qt modules\n  GUILESS     # Optional: do NOT link Qt6::Widgets\n)\n```\n\n### The `GUILESS` flag is load-bearing (linking only)\n\n`GUILESS` controls **one thing**: whether `add_qt_test` adds `Qt6::Gui` + `Qt6::Widgets` to the target. Non-GUILESS targets get them; GUILESS targets do not. This became load-bearing when `core_services` stopped linking `VTextEdit` (which used to leak `Qt::Widgets` in transitively) — see [`../src/core/services/AGENTS.md`](../src/core/services/AGENTS.md#core_services-is-qt-widgets-free-and-vtextedit-free-contract).\n\nIt does **not** — and cannot — choose between `QTEST_MAIN` and `QTEST_GUILESS_MAIN`. That is a source-level macro decision made at the bottom of the test `.cpp`. The two are related but independent:\n\n- A test using `QTEST_GUILESS_MAIN` that merely **includes** a QtWidgets header (without ever constructing a `QApplication`) is correctly labelled `GUILESS` and should list `Qt6::Widgets` explicitly in `LINKS`. `test_clipboard_image`, `test_exportcontroller`, `test_findunitedentry`, `test_missing_nodes_qt`, `test_duplicate_open_guard`, `test_newnotebookcontroller`, and `test_openvnote3notebookcontroller` all do exactly this.\n- Never \"fix\" a missing-QtWidgets compile error by relaxing `core_services`. Add the module the failing target actually needs to that target's `LINKS`.\n\n## Qt Test Macros\n\n| Macro | Purpose |\n|-------|---------|\n| `QTEST_MAIN(Class)` | Creates main(), runs tests with QApplication |\n| `QTEST_GUILESS_MAIN(Class)` | Headless test runner (preferred) |\n| `QVERIFY(condition)` | Assert condition is true |\n| `QCOMPARE(actual, expected)` | Assert equality |\n| `QFETCH(type, name)` | Fetch data-driven test value |\n| `QTest::addColumn<T>(\"name\")` | Declare data column |\n| `QTest::newRow(\"name\")` | Add data row |\n| `QSKIP(\"reason\")` | Skip test |\n| `QTest::ignoreMessage(type, msg)` | Suppress expected qDebug/qWarning/qCritical |\n\n## Test Data Fixtures\n\nTests that need realistic directory structures (e.g., legacy notebook migration) use on-disk fixtures under `tests/data/` instead of hardcoding JSON and file creation in C++.\n\n### Pattern\n\n1. **Create fixture directory** under `tests/data/` with real files on disk\n2. **Locate at runtime** using `QFINDTESTDATA` (works out of the box with Qt6+CMake)\n3. **Copy to temp dir** using `TempDirFixture::copyFrom()` for test isolation\n\n### Example\n\n```cpp\n// In your test class\nQString findFixture(const QString &p_relPath) {\n  // Path is relative to the test source file\n  QString path = QFINDTESTDATA(p_relPath);\n  QVERIFY2_RETURN(!path.isEmpty(),\n    qPrintable(QStringLiteral(\"Fixture not found: %1\").arg(p_relPath)),\n    QString());\n  return path;\n}\n\nvoid TestMyService::testWithFixture() {\n  // Locate fixture (relative to this .cpp file's directory)\n  QString fixturePath = findFixture(\n    QStringLiteral(\"../data/vnote3_notebooks/database_notebook\"));\n  QVERIFY2(!fixturePath.isEmpty(), \"Fixture not found\");\n\n  // Copy to isolated temp dir\n  TempDirFixture workDir;\n  QString sourceDir = workDir.copyFrom(fixturePath, QStringLiteral(\"source\"));\n  QVERIFY2(!sourceDir.isEmpty(), \"Failed to copy fixture\");\n\n  // Test against the copy\n  // ...\n}\n```\n\n### Adding New Fixtures\n\n1. Create directory under `tests/data/` with descriptive name\n2. Add all files the test needs (stubs are fine — content doesn't have to be real)\n3. For Chinese/Unicode filenames, the files must exist on disk with those names\n4. No CMake changes needed — `QFINDTESTDATA` resolves paths automatically via `QT_TESTCASE_SOURCEDIR`\n\n### Existing Fixtures\n\n| Fixture | Path | Description |\n|---------|------|-------------|\n| database_notebook | `tests/data/vnote3_notebooks/database_notebook/` | VNote3 notebook with 17 files (Chinese + ASCII), 1 subfolder, 3 attachment dirs |\n\n## Real Keychain Usage\n\nAll **new or modified tests** that instantiate the real `SyncCredentialsStore` (not a `MockCredentialsStore` / in-memory fake) MUST register a `tests::KeychainGuard` to track and clean up written PAT entries.\n\n### Why\n\nWithout deterministic cleanup, tests leak PAT entries into the host OS keychain. Fixed-ID tests overwrite themselves harmlessly, but UUID-based writes accumulate forever. QtKeychain has no enumerate API (see `src/core/services/synccredentialsstore.cpp:71`), so cleanup cannot be done by sweeping the keychain at startup; it must be driven by tracking each write.\n\n### How\n\n```cpp\n#include \"../helpers/keychain_guard.h\"\n\n// Class-level member:\ntests::KeychainGuard *m_keychainGuard = nullptr;\n\n// In initTestCase(), after registering SyncCredentialsStore:\nauto *credStore = m_services.get<vnotex::SyncCredentialsStore>();\nm_keychainGuard = new tests::KeychainGuard(credStore, this);\n\n// In cleanupTestCase(), BEFORE destroying the vxcore context:\nif (m_keychainGuard) {\n  m_keychainGuard->cleanup();\n  delete m_keychainGuard;\n  m_keychainGuard = nullptr;\n}\n\n// For indirect writes (e.g. via SyncService::enableSyncForNotebook),\n// also call track() defensively in case the credentialsStored signal races:\nm_keychainGuard->track(notebookId);\n```\n\n### Ordering\n\n`cleanup()` MUST run before the vxcore context is destroyed. The guard talks to `SyncCredentialsStore`, which depends on `ServiceLocator` and the live vxcore context; tearing the context down first leaves the guard with dangling references.\n\n### Cross-reference\n\nFor the prod-side cleanup contract (5 sites in `SyncService`), see [`../src/core/services/AGENTS.md` § Credential Cleanup Invariants](../src/core/services/AGENTS.md#credential-cleanup-invariants).\n\n### Mandatory `KeychainGuard` rollout (post-fix-failing-tests plan)\n\nAll sync tests that instantiate `SyncCredentialsStore` (whether through `ServiceLocator` or by local construction) MUST register a `tests::KeychainGuard`. This is non-negotiable: missing guards cause cumulative `notebook_sync_pat_<uuid>` accumulation in the Windows Credential Manager, eventually tripping Win32 error 8 (\"Not enough storage\") which cascades every sync test into failure on subsequent runs.\n\nAs of 2026-06-05, the following tests are compliant:\n\n- `test_synccredentialsstore` (canonical reference)\n- `test_sync_signal_auto_baseline`, `test_sync_signal_baseline`\n- `test_sync_hooks`, `test_sync_ops`, `test_sync_auto_route`, `test_sync_close_block`\n- `test_bootstrap_and_persist`\n- `test_syncservice`, `test_syncservice_lifecycle`\n\n(10 tests total.) New sync tests MUST follow the template in the \"Real Keychain Usage → How\" section above. Tests that do NOT touch the keychain (`test_eventbridge_sync`, `test_notebookcoreservice_sync`) are exempt.\n\n**Operator note (Windows):** the keychain entry surfaces in `cmdkey /list` as `LegacyGeneric:target=notebook_sync_pat_<uuid>` (CRED_TYPE_GENERIC). `KeychainGuard::cleanup()` delegates to `SyncCredentialsStore::deleteCredentials()`, which handles the full target name internally; operators do NOT need to manage the `LegacyGeneric:target=` prefix manually when invoking the guard.\n\n## Current Test Coverage\n\n| Test | Class | Test Cases |\n|------|-------|------------|\n| test_error | `Error`, `ErrorCode` | 23 |\n| test_exception | `Exception` | 20 |\n| test_pathutils | `PathUtils` | 68 |\n| test_htmlutils | `HtmlUtils` | 31 |\n| test_fileutils2 | `FileUtils2` | 15 |\n| test_configservice | `ConfigCoreService` | 10 |\n| test_notebookservice | `NotebookCoreService` | 33 |\n| test_bufferservice | `BufferCoreService` | - |\n| test_buffer | `Buffer2` + `BufferService` | 29 |\n| test_vnote3migrationservice | `VNote3MigrationService` | 46 |\n| test_searchservice | `SearchCoreService` | - |\n| test_servicelocator | `ServiceLocator` | - |\n| test_configmgr2 | `ConfigMgr2` | 7 |\n| test_hookmanager | `HookManager` | 24 |\n| test_hookintegration | Hook integration | 10 |\n| **Total** | | **267+** |\n\n## Debugging Tips\n\n### First-run Windows theme reports\n\nThe Windows CTest registration for `test_themeservice` writes\n`tests/gui/test_themeservice.txt` and `tests/gui/test_themeservice.xml` in the build tree,\nwhile retaining console output. CI includes both in `native-typst-test-report` before\nany diagnostic rerun can overwrite the local files. Inspect these original reports first;\na passing rerun does not establish that the first-run failure was repaired. File reports\ncapture QtTest diagnostics, not failures occurring before QtTest initializes.\n\n### Windows exit code `0xC0000409` is NOT a stack overrun\n\n`0xC0000409` (`STATUS_STACK_BUFFER_OVERRUN`) is misleading. On Windows, Qt's `qFatal()` calls `__fastfail(FAST_FAIL_FATAL_APP_EXIT)`, which the OS reports as `0xC0000409`. The actual cause is almost always a `qFatal()` deep inside Qt, e.g., `QFontDatabase: Must construct a QGuiApplication before accessing QFontDatabase` raised when a `QTEST_GUILESS_MAIN` test transitively touches font-database code.\n\nWhen a test exits with this code, **re-run it with file-based output capture** to surface the real Qt diagnostic:\n\n```powershell\n$env:PATH = \"C:/Qt/6.9.3/msvc2022_64/bin;\" + $env:PATH\n& \"build/.../tests/<category>/test_name.exe\" -o \"output.txt,txt\"\nGet-Content output.txt | Select-String \"QFATAL|FAIL\"\n```\n\nThe `QFATAL : ...` message line names the real cause. Do NOT chase the test for recursion, oversized stack allocations, or fixture loops; that is a false trail. Fix the underlying Qt precondition (switch to `QTEST_MAIN`, guard the GUI-dependent code path, or short-circuit before the offending Qt subsystem is touched).\n\n## Related Modules\n\n- [`../src/core/AGENTS.md`](../src/core/AGENTS.md) — Services and hooks being tested\n- [`../AGENTS.md`](../AGENTS.md) — Code style, architecture overview\n</yakaai_skills>"}