From c88736a99429678959acc12a542dd0eeb4c71c31 Mon Sep 17 00:00:00 2001 From: Douwe de Vries Date: Tue, 21 Jul 2026 22:13:39 +0200 Subject: [PATCH 1/3] =?UTF-8?q?lore(release):=20v0.7.21=20=E2=80=94=20hard?= =?UTF-8?q?en=20data=20and=20delivery=20integrity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve authored Session state, harden generation and cancellation, and make release validation interruption-safe.\n\nValidated with bun run verify and the v0.7.21 release metadata check. --- .github/actions/run-built-app-e2e/action.yml | 8 +- .github/actions/validate-build/action.yml | 2 +- .github/workflows/ci.yml | 17 +- .github/workflows/release.yml | 89 ++- .gitignore | 2 + CHANGELOG.md | 8 + Cargo.lock | 6 +- Cargo.toml | 2 +- .../io.github.ddv1982.qa-scribe.metainfo.xml | 1 + crates/qa-scribe-core/src/ai/executor.rs | 135 ++++- crates/qa-scribe-core/src/ai/stream/codex.rs | 184 +++--- crates/qa-scribe-core/src/attachments/mod.rs | 29 +- crates/qa-scribe-core/src/generation/html.rs | 29 + .../src/generation/html/tests.rs | 45 ++ .../src/generation/html_projection.rs | 4 +- .../qa-scribe-core/src/generation/response.rs | 8 +- .../src/generation/response/images.rs | 177 +++--- .../src/generation/tests/images.rs | 229 ++++++++ .../generation/tests/prompt_and_projection.rs | 64 +++ .../src/generation/tests/response_parser.rs | 110 ++++ .../qa-scribe-core/src/generation/workflow.rs | 20 +- .../src/generation/workflow/tests.rs | 32 +- .../generation/workflow/tests/completion.rs | 92 +++ .../src/generation/workflow/tests/summary.rs | 114 ++++ .../src/services/session_service.rs | 1 + .../services/session_service/attachments.rs | 35 ++ .../src/services/session_service/drafts.rs | 35 +- .../src/services/session_service/entries.rs | 65 ++- .../src/services/session_service/findings.rs | 35 +- .../services/session_service/generation.rs | 35 +- .../src/services/session_service/rich_body.rs | 72 +++ .../qa-scribe-core/tests/session_storage.rs | 7 +- .../tests/session_storage/attachments.rs | 389 +++---------- .../reconciliation_and_session_deletion.rs | 166 ++++++ .../attachments/validation_and_integrity.rs | 236 ++++++++ .../ai_and_drafts.rs | 57 +- docs/ci.md | 31 +- docs/code-size-guidelines.md | 9 +- docs/codebase-audit-2026-07-20.json | 502 ++++++++++++++++ docs/codebase-audit-2026-07-20.md | 543 ++++++++++++++++++ docs/codebase-audit-validation-2026-07-20.md | 67 +++ docs/codebase-remediation-plan-2026-07-20.md | 332 +++++++++++ docs/quality-scenarios.md | 2 +- docs/releasing.md | 43 ++ docs/tauri-threat-model.md | 7 +- e2e/fixtures/bin/codex | 82 ++- e2e/specs/critical-workflows.e2e.mjs | 234 +++++--- e2e/tauri.e2e.conf.json | 2 +- e2e/wdio.conf.mjs | 7 +- frontend/bun.lock | 3 +- frontend/package.json | 3 +- frontend/src/App.test.tsx | 2 +- frontend/src/app/AppOverlays.tsx | 16 +- frontend/src/app/AppShell.tsx | 16 +- frontend/src/app/attachmentActions.test.ts | 104 +++- frontend/src/app/attachmentActions.ts | 262 +++++++-- .../src/app/deleteConfirmationDialog.test.tsx | 25 + frontend/src/app/generationActions.ts | 329 ++++++++--- frontend/src/app/generationActions.types.ts | 53 ++ .../src/app/recordActions.coordination.ts | 276 +++++++++ frontend/src/app/recordActions.drafts.ts | 227 ++++++++ frontend/src/app/recordActions.findings.ts | 232 ++++++++ frontend/src/app/recordActions.ts | 277 ++------- frontend/src/app/sessionActions.saving.ts | 265 +++++++++ frontend/src/app/sessionActions.ts | 259 ++++----- frontend/src/app/sessionActions.writes.ts | 450 +++++++++++++++ frontend/src/app/sessionNavigationActions.ts | 98 ++++ frontend/src/app/types.ts | 13 + ...AppController.autosave.note-writes.test.ts | 206 +++++++ .../useAppController.autosave.records.test.ts | 179 ++++++ ...troller.autosave.recovered-summary.test.ts | 101 ++++ .../src/app/useAppController.autosave.test.ts | 188 +----- ...er.closeProtection.summaryRecovery.test.ts | 316 ++++++++++ .../useAppController.closeProtection.test.ts | 306 ++++++++-- .../src/app/useAppController.derivedState.ts | 96 ++++ ...ller.lifecycle.navigation-recovery.test.ts | 163 ++++++ ...roller.lifecycle.recovery-failures.test.ts | 154 +++++ ...troller.lifecycle.summary-recovery.test.ts | 357 ++++++++++++ .../src/app/useAppController.navigation.ts | 220 +++++++ ...ller.sessionIntegrity.compensation.test.ts | 314 ++++++++++ ...roller.sessionIntegrity.navigation.test.ts | 359 ++++++++++++ .../useAppController.sessionIntegrity.test.ts | 127 ++++ .../src/app/useAppController.testHarness.ts | 3 +- frontend/src/app/useAppController.ts | 323 +++++------ ...seAppController.workflows.deletion.test.ts | 438 ++++++++++++++ ...ontroller.workflows.record-editing.test.ts | 366 ++++++++++++ ...AppController.workflows.settlement.test.ts | 465 +++++++++++++++ .../app/useAppController.workflows.test.ts | 153 ----- frontend/src/app/useAppStartup.ts | 8 +- frontend/src/app/useOutputLibraries.ts | 36 +- .../src/app/usePendingChangeProtection.ts | 20 +- frontend/src/app/useRecordHydration.ts | 43 +- frontend/src/bindings.ts | 1 + frontend/src/editor/RichTextEditor.test.tsx | 112 +++- frontend/src/editor/RichTextEditor.tsx | 65 ++- frontend/src/editor/editorHtml.test.ts | 209 ++++++- frontend/src/editor/editorHtml.ts | 144 ++++- frontend/src/editor/richEditorRegistry.ts | 7 +- .../src/hooks/useSettingsController.test.ts | 311 ++++++++++ frontend/src/hooks/useSettingsController.ts | 115 +++- frontend/src/tauriCommands.ts | 4 + frontend/src/views/FindingsView.tsx | 2 +- frontend/src/views/RecordCollectionView.tsx | 4 +- frontend/src/views/SessionEditorView.tsx | 28 +- frontend/src/views/TestwareView.tsx | 2 +- frontend/src/views/copySuccess.test.tsx | 42 ++ package.json | 2 +- scripts/bump-version.mjs | 29 +- scripts/bump-version.test.mjs | 74 ++- scripts/check-code-size.mjs | 14 +- scripts/check-code-size.test.mjs | 20 + scripts/check-e2e-isolation.mjs | 6 + scripts/code-size-policy.json | 10 +- scripts/install-cargo-audit.mjs | 33 ++ scripts/install-cargo-audit.test.mjs | 49 ++ scripts/run-e2e.mjs | 270 ++++++--- scripts/run-e2e.test.mjs | 189 ++++++ scripts/run-startup-benchmark.mjs | 5 +- scripts/smoke-release-artifacts.mjs | 451 +++++++++++++++ scripts/smoke-release-artifacts.test.mjs | 397 +++++++++++++ scripts/tool-versions.json | 3 +- scripts/version-transaction.mjs | 283 +++++++++ scripts/version-transaction.test.mjs | 319 ++++++++++ src-tauri/build.rs | 1 + .../autogenerated/delete_attachment.toml | 11 + src-tauri/permissions/default.toml | 1 + src-tauri/src/commands/ai/job_events.rs | 10 +- src-tauri/src/commands/ai/job_runner.rs | 105 +++- .../src/commands/ai/provider_execution.rs | 10 +- src-tauri/src/commands/ai/streaming_exec.rs | 26 +- .../src/commands/ai/streaming_exec/tests.rs | 52 +- src-tauri/src/commands/error.rs | 16 +- src-tauri/src/commands/files.rs | 14 +- src-tauri/src/commands/providers.rs | 7 +- src-tauri/src/commands/providers/detection.rs | 43 +- .../src/commands/providers/models/tests.rs | 1 + src-tauri/src/commands/providers/probe.rs | 169 +----- .../src/commands/providers/probe/cancel.rs | 21 +- .../commands/providers/probe/claude/mod.rs | 21 +- .../src/commands/providers/probe/command.rs | 56 +- .../commands/providers/probe/copilot/mod.rs | 29 +- .../providers/probe/copilot/protocol.rs | 7 +- .../providers/probe/lifecycle_tests.rs | 117 ++++ .../src/commands/providers/probe/system.rs | 210 +++++++ src-tauri/src/commands/providers/tests/mod.rs | 41 +- .../src/commands/providers/tests/support.rs | 3 + src-tauri/src/jobs.rs | 311 +++++----- src-tauri/src/jobs/control.rs | 93 +++ src-tauri/src/jobs/tests.rs | 280 +++++++++ src-tauri/src/provider_command.rs | 39 +- src-tauri/src/provider_command/tests.rs | 23 +- src-tauri/src/specta_bindings.rs | 1 + src-tauri/tauri.conf.json | 2 +- 153 files changed, 14794 insertions(+), 2378 deletions(-) create mode 100644 crates/qa-scribe-core/src/services/session_service/rich_body.rs create mode 100644 crates/qa-scribe-core/tests/session_storage/attachments/reconciliation_and_session_deletion.rs create mode 100644 crates/qa-scribe-core/tests/session_storage/attachments/validation_and_integrity.rs create mode 100644 docs/codebase-audit-2026-07-20.json create mode 100644 docs/codebase-audit-2026-07-20.md create mode 100644 docs/codebase-audit-validation-2026-07-20.md create mode 100644 docs/codebase-remediation-plan-2026-07-20.md create mode 100644 frontend/src/app/generationActions.types.ts create mode 100644 frontend/src/app/recordActions.coordination.ts create mode 100644 frontend/src/app/recordActions.drafts.ts create mode 100644 frontend/src/app/recordActions.findings.ts create mode 100644 frontend/src/app/sessionActions.saving.ts create mode 100644 frontend/src/app/sessionActions.writes.ts create mode 100644 frontend/src/app/sessionNavigationActions.ts create mode 100644 frontend/src/app/useAppController.autosave.note-writes.test.ts create mode 100644 frontend/src/app/useAppController.autosave.records.test.ts create mode 100644 frontend/src/app/useAppController.autosave.recovered-summary.test.ts create mode 100644 frontend/src/app/useAppController.closeProtection.summaryRecovery.test.ts create mode 100644 frontend/src/app/useAppController.derivedState.ts create mode 100644 frontend/src/app/useAppController.lifecycle.navigation-recovery.test.ts create mode 100644 frontend/src/app/useAppController.lifecycle.recovery-failures.test.ts create mode 100644 frontend/src/app/useAppController.lifecycle.summary-recovery.test.ts create mode 100644 frontend/src/app/useAppController.navigation.ts create mode 100644 frontend/src/app/useAppController.sessionIntegrity.compensation.test.ts create mode 100644 frontend/src/app/useAppController.sessionIntegrity.navigation.test.ts create mode 100644 frontend/src/app/useAppController.sessionIntegrity.test.ts create mode 100644 frontend/src/app/useAppController.workflows.deletion.test.ts create mode 100644 frontend/src/app/useAppController.workflows.record-editing.test.ts create mode 100644 frontend/src/app/useAppController.workflows.settlement.test.ts create mode 100644 frontend/src/hooks/useSettingsController.test.ts create mode 100644 scripts/install-cargo-audit.mjs create mode 100644 scripts/install-cargo-audit.test.mjs create mode 100644 scripts/run-e2e.test.mjs create mode 100644 scripts/smoke-release-artifacts.mjs create mode 100644 scripts/smoke-release-artifacts.test.mjs create mode 100644 scripts/version-transaction.mjs create mode 100644 scripts/version-transaction.test.mjs create mode 100644 src-tauri/permissions/autogenerated/delete_attachment.toml create mode 100644 src-tauri/src/commands/providers/probe/lifecycle_tests.rs create mode 100644 src-tauri/src/commands/providers/probe/system.rs create mode 100644 src-tauri/src/jobs/control.rs diff --git a/.github/actions/run-built-app-e2e/action.yml b/.github/actions/run-built-app-e2e/action.yml index 2ae56c2..061413e 100644 --- a/.github/actions/run-built-app-e2e/action.yml +++ b/.github/actions/run-built-app-e2e/action.yml @@ -1,7 +1,7 @@ name: Run built application E2E description: >- - Runs the isolated built-application suite, restores and verifies the - production frontend, records optional startup measurements, and retains + Runs the isolated built-application suite, builds and verifies the + production frontend independently, records optional startup measurements, and retains platform-scoped reliability evidence. inputs: @@ -39,10 +39,10 @@ runs: bun run e2e fi - - name: Recheck production build after E2E + - name: Build and check production frontend independently shell: bash if: ${{ always() }} - run: bun run e2e:isolation + run: bun run frontend:build && bun run e2e:isolation - name: Measure large-fixture startup id: startup_benchmark diff --git a/.github/actions/validate-build/action.yml b/.github/actions/validate-build/action.yml index 3fddd00..a5d0cee 100644 --- a/.github/actions/validate-build/action.yml +++ b/.github/actions/validate-build/action.yml @@ -92,7 +92,7 @@ runs: - name: Install cargo-audit shell: bash - run: cargo install cargo-audit --locked + run: node scripts/install-cargo-audit.mjs - name: Audit Rust dependencies shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8272d20..dd55783 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ concurrency: env: CARGO_TERM_COLOR: always FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + RPM_SMOKE_IMAGE: fedora:43@sha256:781b7642e8bf256e9cf75d2aa58d86f5cc695fd2df113517614e181a5eee9138 TAURI_TARGET: x86_64-unknown-linux-gnu jobs: @@ -195,7 +196,9 @@ jobs: rpm2cpio \ libarchive-tools \ desktop-file-utils \ - appstream + appstream \ + xauth \ + xvfb - name: Build frontend run: bun run frontend:build @@ -228,6 +231,18 @@ jobs: fi exit "${validator_status}" + - name: Install deb and launch deb and AppImage on Ubuntu + run: node scripts/smoke-release-artifacts.mjs --linux-deb-appimage dist/rust/artifacts + + - name: Install and launch RPM with dependency resolution on Fedora + run: | + docker run --rm \ + --volume "${PWD}:/workspace:ro" \ + --volume "$(command -v bun):/usr/local/bin/bun:ro" \ + --workdir /workspace \ + "${RPM_SMOKE_IMAGE}" \ + bun scripts/smoke-release-artifacts.mjs --linux-rpm dist/rust/artifacts + - name: Validate APT repository generation run: node scripts/check-apt-repository.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b006af..24a13cd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,7 @@ concurrency: env: CARGO_TERM_COLOR: always FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + RPM_SMOKE_IMAGE: fedora:43@sha256:781b7642e8bf256e9cf75d2aa58d86f5cc695fd2df113517614e181a5eee9138 jobs: release-context: @@ -257,20 +258,33 @@ jobs: certificate_path="${RUNNER_TEMP}/developer-id.p12" keychain_path="${RUNNER_TEMP}/qa-scribe-signing.keychain-db" + previous_keychains_path="${RUNNER_TEMP}/qa-scribe-previous-keychains.txt" + previous_keychains_staging_path="${previous_keychains_path}.next" keychain_password="$(uuidgen)" api_key_path="${RUNNER_TEMP}/AuthKey_${APPLE_API_KEY_ID}.p8" MACOS_DEVELOPER_ID="${MACOS_DEVELOPER_ID_INPUT}" MACOS_TEAM_ID="${MACOS_TEAM_ID_INPUT}" + echo "APPLE_API_KEY_PATH=${api_key_path}" >> "$GITHUB_ENV" printf '%s' "$CSC_LINK" | base64 --decode > "$certificate_path" printf '%s' "$APPLE_API_KEY_BASE64" | base64 --decode > "$api_key_path" + security list-keychains -d user | awk -F'"' 'NF >= 2 { print $2 }' > "$previous_keychains_staging_path" + mv "$previous_keychains_staging_path" "$previous_keychains_path" + previous_keychains=() + while IFS= read -r previous_keychain; do + [ -n "$previous_keychain" ] && previous_keychains+=("$previous_keychain") + done < "$previous_keychains_path" + security create-keychain -p "$keychain_password" "$keychain_path" security set-keychain-settings -lut 21600 "$keychain_path" security unlock-keychain -p "$keychain_password" "$keychain_path" security import "$certificate_path" -k "$keychain_path" -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security - security list-keychains -d user -s "$keychain_path" - security default-keychain -d user -s "$keychain_path" + if [ ${#previous_keychains[@]} -gt 0 ]; then + security list-keychains -d user -s "$keychain_path" "${previous_keychains[@]}" + else + security list-keychains -d user -s "$keychain_path" + fi security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain_path" if [ -z "${MACOS_DEVELOPER_ID}" ]; then @@ -296,7 +310,6 @@ jobs: if [ -n "${MACOS_TEAM_ID}" ]; then echo "MACOS_TEAM_ID=${MACOS_TEAM_ID}" >> "$GITHUB_ENV" fi - echo "APPLE_API_KEY_PATH=${api_key_path}" >> "$GITHUB_ENV" - name: Sign and notarize macOS app bundle env: @@ -345,6 +358,57 @@ jobs: - name: Validate macOS artifacts run: node scripts/check-rust-artifacts.mjs --platform macos --require-dmg + - name: Clean up Apple signing environment + if: always() + run: | + set -euo pipefail + + keychain_path="${RUNNER_TEMP}/qa-scribe-signing.keychain-db" + certificate_path="${RUNNER_TEMP}/developer-id.p12" + previous_keychains_path="${RUNNER_TEMP}/qa-scribe-previous-keychains.txt" + previous_keychains_staging_path="${previous_keychains_path}.next" + api_key_path="${APPLE_API_KEY_PATH:-}" + if [ -n "${api_key_path}" ] && [[ "${api_key_path}" != "${RUNNER_TEMP}"/AuthKey_*.p8 ]]; then + echo "Refusing to remove unexpected Apple API key path: ${api_key_path}" >&2 + exit 1 + fi + + cleanup_failed=0 + if [ -f "${previous_keychains_path}" ]; then + previous_keychains=() + while IFS= read -r previous_keychain; do + [ -n "$previous_keychain" ] && previous_keychains+=("$previous_keychain") + done < "$previous_keychains_path" + if [ ${#previous_keychains[@]} -gt 0 ]; then + security list-keychains -d user -s "${previous_keychains[@]}" || cleanup_failed=1 + else + security list-keychains -d user -s || cleanup_failed=1 + fi + fi + + security delete-keychain "${keychain_path}" >/dev/null 2>&1 || true + rm -f "${keychain_path}" "${certificate_path}" "${previous_keychains_path}" "${previous_keychains_staging_path}" + if [ -n "${api_key_path}" ]; then + rm -f "${api_key_path}" + fi + + for sensitive_path in "${keychain_path}" "${certificate_path}" "${api_key_path}"; do + [ -n "${sensitive_path}" ] || continue + if [ -e "${sensitive_path}" ]; then + echo "Could not remove temporary Apple signing material: ${sensitive_path}" >&2 + exit 1 + fi + done + + echo 'APPLE_API_KEY_PATH=' >> "${GITHUB_ENV}" + if [ "${cleanup_failed}" -ne 0 ]; then + echo 'Could not restore the original user keychain search list.' >&2 + exit 1 + fi + + - name: Mount, copy, and launch final macOS DMG + run: node scripts/smoke-release-artifacts.mjs --macos-dmg dist/rust/artifacts + - name: Stage macOS release assets uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -389,7 +453,9 @@ jobs: libarchive-tools \ desktop-file-utils \ appstream \ - gnupg + gnupg \ + xauth \ + xvfb - name: Build frontend run: bun run frontend:build @@ -426,6 +492,18 @@ jobs: fi exit "${validator_status}" + - name: Install deb and launch deb and AppImage on Ubuntu + run: node scripts/smoke-release-artifacts.mjs --linux-deb-appimage dist/rust/artifacts + + - name: Install and launch RPM with dependency resolution on Fedora + run: | + docker run --rm \ + --volume "${PWD}:/workspace:ro" \ + --volume "$(command -v bun):/usr/local/bin/bun:ro" \ + --workdir /workspace \ + "${RPM_SMOKE_IMAGE}" \ + bun scripts/smoke-release-artifacts.mjs --linux-rpm dist/rust/artifacts + - name: Build signed APT repository and checksums env: DEB_SIGNING_PRIVATE_KEY: ${{ secrets.DEB_SIGNING_PRIVATE_KEY }} @@ -511,6 +589,9 @@ jobs: "${RUNNER_TEMP}/linux-signing-public-key.asc" \ "${RUNNER_TEMP}/linux-signing-passphrase" + - name: Install and verify final APT setup package + run: node scripts/smoke-release-artifacts.mjs --apt-setup dist/rust/artifacts/qa-scribe-repository-setup_1.0_all.deb --expected-keyring dist/rust/artifacts/qa-scribe-archive-keyring.pgp + - name: Stage APT repository installer env: LINUX_GPG_FINGERPRINT: ${{ secrets.DEB_SIGNING_KEY_FINGERPRINT }} diff --git a/.gitignore b/.gitignore index 9a9487c..1d7a817 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ __pycache__/ *.pyc qa-scribe.sqlite qa-scribe.sqlite-* +.qa-scribe-version-transaction.json* +**/.*.qa-scribe-bump-* diff --git a/CHANGELOG.md b/CHANGELOG.md index 81ca174..0fb71b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v0.7.21 - 2026-07-21 + +- Preserve authored Session work across recovered Summary completion, overlapping saves, blank-title validation, discard, navigation, active-Session reselection, and application close. +- Clean up abandoned image imports, add safe attachment deletion, and recover managed attachment previews with deduplicated reads and bounded automatic retries. +- Harden local AI execution with broadcast-first cancellation, private neutral working directories, bounded diagnostics, stricter structured-response parsing, and exact Evidence image identity. +- Make validation and releases more deterministic with isolated E2E builds, pinned audit tooling, recoverable version transactions, and dependency-resolving final-package installation and launch smokes. +- Split oversized persistence, controller, action, and regression-test modules along cohesive boundaries, backed by a traceable codebase audit and expanded race coverage. + ## v0.7.20 - 2026-07-15 - Preserve the latest Testware and Finding edits across overlapping saves, Session changes, and hydration, while rejecting malformed navigation routes and empty AI-generated content before it can replace authored work. diff --git a/Cargo.lock b/Cargo.lock index 6475b30..e752756 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2765,7 +2765,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "qa-scribe-app" -version = "0.7.20" +version = "0.7.21" dependencies = [ "qa-scribe-core", "serde_json", @@ -2773,7 +2773,7 @@ dependencies = [ [[package]] name = "qa-scribe-core" -version = "0.7.20" +version = "0.7.21" dependencies = [ "base64 0.22.1", "chrono", @@ -2788,7 +2788,7 @@ dependencies = [ [[package]] name = "qa-scribe-tauri" -version = "0.7.20" +version = "0.7.21" dependencies = [ "base64 0.22.1", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 919e925..15ff66c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ resolver = "3" [workspace.package] -version = "0.7.20" +version = "0.7.21" edition = "2024" license = "MIT" authors = ["qa-scribe contributors"] diff --git a/build/linux/io.github.ddv1982.qa-scribe.metainfo.xml b/build/linux/io.github.ddv1982.qa-scribe.metainfo.xml index ec10f3a..fafcdb4 100644 --- a/build/linux/io.github.ddv1982.qa-scribe.metainfo.xml +++ b/build/linux/io.github.ddv1982.qa-scribe.metainfo.xml @@ -18,6 +18,7 @@ ProjectManagement + diff --git a/crates/qa-scribe-core/src/ai/executor.rs b/crates/qa-scribe-core/src/ai/executor.rs index 2803d3f..d496bf0 100644 --- a/crates/qa-scribe-core/src/ai/executor.rs +++ b/crates/qa-scribe-core/src/ai/executor.rs @@ -11,7 +11,7 @@ use crate::domain::AiProvider; use super::{ - GenerationCommand, + GenerationCommand, GenerationOutputFormat, stream::{ProviderStreamParser, StreamUpdate}, }; @@ -88,6 +88,7 @@ pub fn run_streaming_generation( stdout, stderr: execution.stderr, assistant_text: parser.finish(), + output_format: command.output_format, cancelled: execution.cancelled, }) } @@ -100,6 +101,7 @@ pub struct ProviderGenerationOutput { pub stdout: Vec, pub stderr: Vec, pub assistant_text: Option, + pub output_format: GenerationOutputFormat, pub cancelled: bool, } @@ -108,12 +110,33 @@ impl ProviderGenerationOutput { !self.cancelled && self.exit_success == Some(true) } - pub fn response_text(&self) -> String { - self.assistant_text + /// Return the provider's assistant response without confusing structured + /// protocol envelopes for generated content. + /// + /// Plain-text providers may use their raw stdout as a compatibility + /// fallback. Structured providers must produce assistant content through + /// their format-specific parser; falling back to JSONL/stdout would make + /// an unknown or malformed event look like a valid generated response. + pub fn response_text(&self) -> Result { + if let Some(text) = self + .assistant_text .as_ref() .filter(|text| !text.trim().is_empty()) - .cloned() - .unwrap_or_else(|| String::from_utf8_lossy(&self.stdout).to_string()) + { + return Ok(text.clone()); + } + + match self.output_format { + GenerationOutputFormat::PlainText => { + Ok(String::from_utf8_lossy(&self.stdout).to_string()) + } + GenerationOutputFormat::CodexJsonl => { + Err(structured_output_compatibility_error("Codex JSONL")) + } + GenerationOutputFormat::ClaudeStreamJson => { + Err(structured_output_compatibility_error("Claude stream-json")) + } + } } /// Structured CLIs such as Claude include the resolved model in their @@ -162,6 +185,12 @@ impl ProviderGenerationOutput { } } +fn structured_output_compatibility_error(format: &str) -> String { + format!( + "Provider completed successfully, but QA Scribe found no recognized assistant content in its {format} output. The installed provider CLI may use an unsupported event format; update QA Scribe or use a compatible provider CLI version, then try again." + ) +} + fn bounded_text_tail(value: &str, limit: usize) -> String { if value.len() <= limit { return value.to_string(); @@ -221,6 +250,7 @@ mod tests { stdout: Vec::new(), stderr: stderr.to_vec(), assistant_text: None, + output_format: GenerationOutputFormat::PlainText, cancelled: false, } } @@ -283,7 +313,10 @@ mod tests { .expect("scripted run succeeds"); assert!(output.success()); - assert_eq!(output.response_text(), "Hello world"); + assert_eq!( + output.response_text().expect("recognized Codex response"), + "Hello world" + ); assert!(updates >= 2, "each delta should surface an update"); assert!( String::from_utf8_lossy(&output.stdout).contains("agentMessage"), @@ -301,6 +334,7 @@ mod tests { .to_vec(), stderr: Vec::new(), assistant_text: Some("done".to_string()), + output_format: GenerationOutputFormat::ClaudeStreamJson, cancelled: false, }; @@ -310,6 +344,95 @@ mod tests { ); } + #[test] + fn current_structured_provider_fixtures_return_only_assistant_content() { + let fixtures = [ + ( + GenerationOutputFormat::CodexJsonl, + vec![ + r#"{"type":"thread.started","thread_id":"thread-1"}"#, + r#"{"type":"item.completed","item":{"type":"agent_message","text":"

Codex cases

"}}"#, + r#"{"type":"turn.completed","usage":{"input_tokens":10}}"#, + ], + "

Codex cases

", + ), + ( + GenerationOutputFormat::ClaudeStreamJson, + vec![ + r#"{"type":"system","subtype":"init","model":"claude-sonnet"}"#, + r#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"

Claude cases

"}}}"#, + r#"{"type":"result","result":"

Claude cases

"}"#, + ], + "

Claude cases

", + ), + ]; + + for (output_format, lines, expected) in fixtures { + let command = GenerationCommand { + program: "scripted".to_string(), + args: Vec::new(), + stdin: "prompt".to_string(), + output_format, + }; + let output = run_streaming_generation(&ScriptedExecutor(lines), &command, |_| {}) + .expect("fixture execution succeeds"); + + assert_eq!( + output.response_text().expect("fixture assistant content"), + expected + ); + } + } + + #[test] + fn structured_unknown_or_malformed_events_return_compatibility_error_not_raw_stdout() { + for (output_format, lines, format_label) in [ + ( + GenerationOutputFormat::CodexJsonl, + vec![r#"{"type":"future.response","payload":"

raw Codex protocol

"}"#], + "Codex JSONL", + ), + ( + GenerationOutputFormat::ClaudeStreamJson, + vec![r#"{"type":"assistant","message":"#], + "Claude stream-json", + ), + ] { + let command = GenerationCommand { + program: "scripted".to_string(), + args: Vec::new(), + stdin: "prompt".to_string(), + output_format, + }; + let output = run_streaming_generation(&ScriptedExecutor(lines), &command, |_| {}) + .expect("zero-exit provider execution remains classifiable"); + + let error = output + .response_text() + .expect_err("unrecognized structured output must fail"); + assert!(error.contains(format_label), "unexpected error: {error}"); + assert!(error.contains("unsupported event format")); + assert!(!error.contains("raw Codex protocol")); + } + } + + #[test] + fn plain_text_response_keeps_raw_stdout_fallback() { + let output = ProviderGenerationOutput { + exit_success: Some(true), + stdout: b"

Plain provider response

\n".to_vec(), + stderr: Vec::new(), + assistant_text: None, + output_format: GenerationOutputFormat::PlainText, + cancelled: false, + }; + + assert_eq!( + output.response_text().expect("plain stdout fallback"), + "

Plain provider response

\n" + ); + } + #[test] fn core_rejects_output_over_the_shared_safety_limit() { struct OversizedExecutor; diff --git a/crates/qa-scribe-core/src/ai/stream/codex.rs b/crates/qa-scribe-core/src/ai/stream/codex.rs index 2285a92..897e508 100644 --- a/crates/qa-scribe-core/src/ai/stream/codex.rs +++ b/crates/qa-scribe-core/src/ai/stream/codex.rs @@ -2,21 +2,15 @@ //! //! One JSON object per line. The events this parser keys on: //! -//! * explicit final-text keys (`result`, `final`, `finalMessage`, -//! `lastMessage`, `output`) — the full answer, subject to the final-text -//! guard (Codex does not guarantee such an event, so a genuine final may -//! replace but never shrink the accumulated text); //! * delta events (`item/agentMessage/delta`, legacy `agent_message_delta` //! inside a `msg` wrapper) — appended assistant text; -//! * `item.*` lifecycle events — only agent-message items carry assistant -//! text; reasoning/command items must never clobber the answer and are -//! reported as progress instead. +//! * completed `agent_message` items — the assistant response snapshot; +//! * every other lifecycle, reasoning, command, tool, or unknown event — +//! progress only, regardless of any text-like fields it contains. use serde_json::Value; -use super::{AssistantText, StreamUpdate, joined_text, progress_for_event}; - -const FINAL_TEXT_KEYS: [&str; 5] = ["result", "final", "finalMessage", "lastMessage", "output"]; +use super::{AssistantText, StreamUpdate, progress_for_event}; #[derive(Default)] pub(super) struct CodexJsonlParser { @@ -25,30 +19,40 @@ pub(super) struct CodexJsonlParser { impl CodexJsonlParser { pub(super) fn push_event(&mut self, value: &Value) -> Vec { - let event_name = event_name(value); - let name = event_name.as_deref().unwrap_or_default(); - - if let Some(final_text) = explicit_final_text(value) - && let Some(update) = self.assistant_text.replace_if_not_shorter(final_text) - { - return vec![update]; - } - - if (name.contains("delta") || name.contains("partial")) - && let Some(delta) = delta_text(value) - { - return vec![self.assistant_text.append(&delta)]; - } - - if let Some(item) = value.get("item") - && item_kind(item).is_some_and(|kind| kind.contains("message")) - && let Some(snapshot) = joined_text(item) - && let Some(update) = self.assistant_text.replace_if_not_shorter(snapshot) + let modern_event = value.get("type").and_then(Value::as_str); + let legacy_message = value.get("msg"); + let legacy_event = legacy_message + .and_then(|message| message.get("type")) + .and_then(Value::as_str); + + if legacy_message.is_none() { + match modern_event { + Some("item/agentMessage/delta") => { + if let Some(delta) = value.get("delta").and_then(non_empty_string) { + return vec![self.assistant_text.append(delta)]; + } + } + Some("item.completed") => { + if let Some(snapshot) = completed_agent_message_text(value) + && let Some(update) = self + .assistant_text + .replace_if_not_shorter(snapshot.to_string()) + { + return vec![update]; + } + } + _ => {} + } + } else if modern_event.is_none() + && legacy_event == Some("agent_message_delta") + && let Some(delta) = legacy_message + .and_then(|message| message.get("delta")) + .and_then(non_empty_string) { - return vec![update]; + return vec![self.assistant_text.append(delta)]; } - progress_for_event(event_name.as_deref()) + progress_for_event(modern_event.or(legacy_event)) } pub(super) fn finish(self) -> Option { @@ -56,39 +60,16 @@ impl CodexJsonlParser { } } -/// Event name from the modern `type` key, the legacy `msg.type` wrapper, or a -/// JSON-RPC style `method` key. -fn event_name(value: &Value) -> Option { - value - .get("msg") - .and_then(|msg| msg.get("type")) - .or_else(|| value.get("type")) - .or_else(|| value.get("method")) - .and_then(Value::as_str) - .map(ToString::to_string) -} - -fn explicit_final_text(value: &Value) -> Option { - FINAL_TEXT_KEYS.iter().find_map(|key| { - value - .get(*key) - .and_then(Value::as_str) - .filter(|text| !text.trim().is_empty()) - .map(ToString::to_string) - }) -} - -fn delta_text(value: &Value) -> Option { - value - .get("delta") - .or_else(|| value.get("msg").and_then(|msg| msg.get("delta"))) - .and_then(joined_text) +fn non_empty_string(value: &Value) -> Option<&str> { + value.as_str().filter(|text| !text.trim().is_empty()) } -fn item_kind(item: &Value) -> Option<&str> { - item.get("type") - .or_else(|| item.get("item_type")) - .and_then(Value::as_str) +fn completed_agent_message_text(value: &Value) -> Option<&str> { + let item = value.get("item")?; + if item.get("type").and_then(Value::as_str) != Some("agent_message") { + return None; + } + item.get("text").and_then(non_empty_string) } #[cfg(test)] @@ -143,20 +124,14 @@ mod tests { } #[test] - fn stream_parser_genuine_final_result_still_replaces_streamed_partials() { + fn unknown_result_event_cannot_replace_streamed_assistant_text() { let mut parser = parser(); parser.push_bytes(br#"{"type":"item/agentMessage/delta","delta":"partial"}"#); + parser + .push_bytes(br#"{"type":"result","result":"The complete but unknown response shape"}"#); - let full_answer = "The complete final answer, longer than the partial streamed text."; - let updates = parser - .push_bytes(format!(r#"{{"type":"result","result":"{full_answer}"}}"#).as_bytes()); - - assert!(matches!( - updates.last(), - Some(StreamUpdate::PartialSnapshot(body)) if body == full_answer - )); - assert_eq!(parser.finish().as_deref(), Some(full_answer)); + assert_eq!(parser.finish().as_deref(), Some("partial")); } #[test] @@ -188,4 +163,69 @@ mod tests { )); assert_eq!(parser.finish().as_deref(), Some("Hi there")); } + + #[test] + fn unknown_text_like_fields_never_become_assistant_content() { + let mut parser = parser(); + + for event in [ + br#"{"type":"future.response","output":"unknown output"}"#.as_slice(), + br#"{"type":"turn.completed","result":"unknown result"}"#.as_slice(), + br#"{"method":"item/agentMessage/delta","delta":"json-rpc text"}"#.as_slice(), + br#"{"type":"item/agentMessage/partial","delta":"unknown partial"}"#.as_slice(), + ] { + parser.push_bytes(event); + } + + assert_eq!(parser.finish(), None); + } + + #[test] + fn reasoning_and_tool_deltas_never_become_assistant_content() { + let mut parser = parser(); + + for event in [ + br#"{"type":"item/reasoning/delta","delta":"private reasoning"}"#.as_slice(), + br#"{"type":"item/tool/delta","delta":"tool output"}"#.as_slice(), + br#"{"type":"item/command/delta","delta":"command output"}"#.as_slice(), + br#"{"type":"item/agentMessage/delta","delta":{"text":"nested unknown delta"}}"# + .as_slice(), + ] { + parser.push_bytes(event); + } + + assert_eq!(parser.finish(), None); + } + + #[test] + fn non_agent_completed_items_never_become_assistant_content() { + let mut parser = parser(); + + for event in [ + br#"{"type":"item.completed","item":{"type":"reasoning","text":"reasoning"}}"#.as_slice(), + br#"{"type":"item.completed","item":{"type":"tool_message","text":"tool"}}"#.as_slice(), + br#"{"type":"item.completed","item":{"type":"user_message","text":"user"}}"#.as_slice(), + br#"{"type":"item.completed","item":{"item_type":"agent_message","text":"legacy guess"}}"#.as_slice(), + ] { + parser.push_bytes(event); + } + + assert_eq!(parser.finish(), None); + } + + #[test] + fn modern_and_legacy_envelopes_cannot_be_spliced() { + let mut parser = parser(); + + for event in [ + br#"{"type":"future.event","delta":"spliced top delta","msg":{"type":"item/agentMessage/delta"}}"#.as_slice(), + br#"{"type":"item/agentMessage/delta","delta":"modern delta","msg":{"type":"progress"}}"#.as_slice(), + br#"{"type":"future.event","msg":{"type":"agent_message_delta","delta":"legacy delta"}}"#.as_slice(), + br#"{"type":"item.completed","item":{"type":"agent_message","text":"modern snapshot"},"msg":{"type":"progress"}}"#.as_slice(), + ] { + parser.push_bytes(event); + } + + assert_eq!(parser.finish(), None); + } } diff --git a/crates/qa-scribe-core/src/attachments/mod.rs b/crates/qa-scribe-core/src/attachments/mod.rs index f8be038..ccf4235 100644 --- a/crates/qa-scribe-core/src/attachments/mod.rs +++ b/crates/qa-scribe-core/src/attachments/mod.rs @@ -194,7 +194,34 @@ pub fn delete_session_with_attachment_files( // stray files behind, which reconcile_attachment_files already detects // and reports without treating them as data loss. service.delete_session(session_id)?; - delete_session_attachment_files(app_data_dir, session_id) + let _ = delete_session_attachment_files(app_data_dir, session_id); + Ok(()) +} + +pub fn delete_attachment_with_file( + service: &SessionService, + app_data_dir: impl AsRef, + attachment_id: &str, +) -> Result { + let Some(attachment) = service.get_attachment(attachment_id)? else { + return Ok(true); + }; + let relative_path = Path::new(&attachment.relative_path); + if !is_safe_relative_path(relative_path) { + return Err(validation("attachment path is invalid")); + } + if service.attachment_is_referenced(attachment_id)? { + return Ok(false); + } + let path = app_data_dir.as_ref().join(relative_path); + if path.exists() { + // Remove the file first so a filesystem failure leaves the row intact + // and the cleanup can be retried. If the later row delete fails, the + // same retry can finish deleting the now-fileless, unreferenced row. + fs::remove_file(path)?; + } + service.delete_attachment(attachment_id)?; + Ok(true) } pub fn delete_session_attachment_files( diff --git a/crates/qa-scribe-core/src/generation/html.rs b/crates/qa-scribe-core/src/generation/html.rs index 30db2c4..47276ee 100644 --- a/crates/qa-scribe-core/src/generation/html.rs +++ b/crates/qa-scribe-core/src/generation/html.rs @@ -31,6 +31,35 @@ pub(super) fn skip_whitespace(value: &str, mut index: usize) -> usize { index } +/// Find the `>` that closes an HTML tag whose body starts at `start`, +/// ignoring greater-than characters inside single- or double-quoted +/// attribute values. +/// +/// HTML does not use backslash escapes for attribute quotes, so quote state +/// changes only on the matching literal quote. An unclosed quote therefore +/// means the tag has no trustworthy boundary and returns `None`. Callers can +/// then treat the leading `<` as text instead of splitting a malformed tag at +/// an attribute delimiter. Byte indices are accepted and returned so callers +/// can slice the original fragment without allocation; invalid UTF-8 +/// boundaries are rejected rather than panicking. +pub(super) fn find_html_tag_end(value: &str, start: usize) -> Option { + if start > value.len() || !value.is_char_boundary(start) { + return None; + } + + let mut quote = None; + for (relative_index, character) in value[start..].char_indices() { + match quote { + Some(open_quote) if character == open_quote => quote = None, + Some(_) => {} + None if character == '"' || character == '\'' => quote = Some(character), + None if character == '>' => return Some(start + relative_index), + None => {} + } + } + None +} + /// Extract the value of `attribute` from a raw (already `<`/`>`-stripped) /// tag body, e.g. `img src="foo.png" alt="bar"`. Attribute values are /// entity-decoded (using the wide [`decode_html_entities`] decoder) before diff --git a/crates/qa-scribe-core/src/generation/html/tests.rs b/crates/qa-scribe-core/src/generation/html/tests.rs index 6145c9b..b89a485 100644 --- a/crates/qa-scribe-core/src/generation/html/tests.rs +++ b/crates/qa-scribe-core/src/generation/html/tests.rs @@ -55,6 +55,51 @@ fn skip_whitespace_is_a_no_op_when_not_on_whitespace() { assert_eq!(skip_whitespace("abc", 0), 0); } +#[test] +fn html_tag_end_ignores_greater_than_inside_both_quote_styles() { + let value = r#"A > Btail"#; + let tag_end = find_html_tag_end(value, 1).expect("quoted tag should close"); + + assert_eq!(&value[tag_end..], ">tail"); +} + +#[test] +fn html_tag_end_rejects_unclosed_quotes_and_invalid_utf8_boundaries() { + assert_eq!(find_html_tag_end(r#"A > B B", + "café > naïve", + "日本語 > ✅", + "nested-looking value > threshold", + "entity > plus literal > delimiter", + ]; + + for quote in ['"', '\''] { + for payload in payloads { + let value = format!( + "{quote}{payload}{quote} quoted{quote}>tail" + ); + let expected = value.rfind(">tail").expect("fixture has final tag end"); + + assert_eq!( + find_html_tag_end(&value, 1), + Some(expected), + "failed quote={quote:?}, payload={payload:?}" + ); + } + } +} + #[test] fn decode_html_entities_covers_the_union_of_known_entities() { assert_eq!(decode_html_entities("&<>""), "&<>\""); diff --git a/crates/qa-scribe-core/src/generation/html_projection.rs b/crates/qa-scribe-core/src/generation/html_projection.rs index e69897b..f167b0f 100644 --- a/crates/qa-scribe-core/src/generation/html_projection.rs +++ b/crates/qa-scribe-core/src/generation/html_projection.rs @@ -1,5 +1,6 @@ use super::html::{ MANAGED_ATTACHMENT_PROTOCOL, attribute_value, decode_html_entities, find_case_insensitive, + find_html_tag_end, }; pub fn project_html_to_prompt_text(value: &str) -> String { @@ -28,13 +29,12 @@ impl<'a> HtmlPromptProjector<'a> { let tag_start = index + relative_tag_start; self.push_text(&self.source[index..tag_start]); - let Some(relative_tag_end) = self.source[tag_start..].find('>') else { + let Some(tag_end) = find_html_tag_end(self.source, tag_start + 1) else { self.push_text(&self.source[tag_start..]); index = self.source.len(); break; }; - let tag_end = tag_start + relative_tag_end; let raw_tag = &self.source[tag_start + 1..tag_end]; let tag = Tag::parse(raw_tag); index = tag_end + 1; diff --git a/crates/qa-scribe-core/src/generation/response.rs b/crates/qa-scribe-core/src/generation/response.rs index 8a395e7..81b7907 100644 --- a/crates/qa-scribe-core/src/generation/response.rs +++ b/crates/qa-scribe-core/src/generation/response.rs @@ -1,6 +1,6 @@ use super::html::{ MANAGED_ATTACHMENT_PROTOCOL, attribute_value_with_decoder, decode_basic_html_entities, - escape_html_attribute, find_case_insensitive, + escape_html_attribute, find_case_insensitive, find_html_tag_end, }; mod images; @@ -184,12 +184,11 @@ fn sanitize_editor_html_fragment(value: &str) -> String { continue; } - let Some(relative_end) = value[tag_start..].find('>') else { + let Some(tag_end) = find_html_tag_end(value, tag_start + 1) else { output.push_str("<"); index = tag_start + 1; continue; }; - let tag_end = tag_start + relative_end; let raw_tag = &value[tag_start + 1..tag_end]; match sanitize_editor_tag(raw_tag) { SanitizedTag::Keep(html) => output.push_str(&html), @@ -393,8 +392,7 @@ fn find_closing_tag_end(value: &str, start: usize, tag_name: &str) -> Option')?; - Some(close_start + close_end + 1) + find_html_tag_end(value, close_start + 2).map(|close_end| close_end + 1) } fn should_decode_escaped_editor_html(value: &str) -> bool { diff --git a/crates/qa-scribe-core/src/generation/response/images.rs b/crates/qa-scribe-core/src/generation/response/images.rs index 073b017..84c9cd5 100644 --- a/crates/qa-scribe-core/src/generation/response/images.rs +++ b/crates/qa-scribe-core/src/generation/response/images.rs @@ -1,18 +1,25 @@ +use std::collections::{HashMap, HashSet}; + use crate::domain::Attachment; use super::{attribute_value, clean_managed_attachment_id, image_html, managed_image_html}; -use crate::generation::html::{MANAGED_ATTACHMENT_PROTOCOL, find_case_insensitive}; +use crate::generation::html::{MANAGED_ATTACHMENT_PROTOCOL, find_html_tag_end}; pub fn preserve_managed_attachment_images( response: &str, original_note_html: &str, attachments: &[Attachment], ) -> String { - let mut output = restore_known_attachment_sources(response.trim(), attachments); - let original_images = preservable_images_from_html(original_note_html, attachments); + let sanitized = super::sanitize_generated_rich_html(response.trim()); + let mut output = restore_known_attachment_sources(&sanitized, attachments); + let original_images = preservable_images_from_html(original_note_html, attachments, true); + let mut present_image_keys = preservable_images_from_html(&output, attachments, false) + .into_iter() + .map(|image| image.key) + .collect::>(); for image in original_images { - if image_already_present(&output, &image) { + if !present_image_keys.insert(image.key) { continue; } if !output.is_empty() && !output.ends_with('\n') { @@ -29,77 +36,107 @@ pub fn preserve_managed_attachment_images( #[derive(Clone, Debug, Eq, PartialEq)] struct PreservableImage { key: String, - managed_attachment_id: Option, html: String, } -fn image_already_present(output: &str, image: &PreservableImage) -> bool { - image - .managed_attachment_id - .as_deref() - .map(|id| contains_managed_attachment(output, id)) - .unwrap_or_else(|| output.contains(&image.key)) -} - -/// Rewrites AI-echoed relative attachment paths to the real managed source. -/// Relative paths are attachment-unique. Bare filenames are restored only -/// when exactly one attachment has that filename, so evidence is never -/// silently attributed to the wrong attachment. +/// Rewrites sanitizer-confirmed image sources only when they identify one +/// attachment across both relative paths and filenames. fn restore_known_attachment_sources(value: &str, attachments: &[Attachment]) -> String { - let mut output = value.to_string(); - for attachment in attachments { - if !attachment.relative_path.trim().is_empty() { - output = replace_image_source(&output, &attachment.relative_path, &attachment.id); - } - } - for attachment in attachments { - if attachment.filename.trim().is_empty() - || !filename_is_unique(attachments, &attachment.filename) - { - continue; + let known_sources = unique_attachment_sources(attachments); + let mut output = String::with_capacity(value.len()); + let mut offset = 0; + while let Some(relative_start) = value[offset..].find(" bool { - attachments - .iter() - .filter(|attachment| attachment.filename == filename) - .count() - == 1 -} - -fn replace_image_source(value: &str, source: &str, attachment_id: &str) -> String { - let mut output = value.to_string(); - for quote in ['"', '\''] { - let needle = format!("src={quote}{source}{quote}"); - let replacement = format!( - "src={quote}{MANAGED_ATTACHMENT_PROTOCOL}{attachment_id}{quote} data-attachment-id={quote}{attachment_id}{quote}" - ); - output = output.replace(&needle, &replacement); +fn unique_attachment_sources(attachments: &[Attachment]) -> HashMap<&str, Option<&str>> { + let mut sources: HashMap<&str, Option<&str>> = HashMap::new(); + for attachment in attachments { + for source in [ + attachment.relative_path.as_str(), + attachment.filename.as_str(), + ] { + if source.trim().is_empty() { + continue; + } + sources + .entry(source) + .and_modify(|matched_id| { + if matched_id.is_some_and(|id| id != attachment.id.as_str()) { + *matched_id = None; + } + }) + .or_insert(Some(attachment.id.as_str())); + } } - output + sources } -fn preservable_images_from_html(value: &str, attachments: &[Attachment]) -> Vec { +fn preservable_images_from_html( + value: &str, + attachments: &[Attachment], + infer_known_paths: bool, +) -> Vec { let mut images = Vec::new(); + let mut image_keys = HashSet::new(); let mut offset = 0usize; - while let Some(relative_start) = find_case_insensitive(&value[offset..], "') else { - break; + if value[tag_start..].starts_with("") else { + break; + }; + offset = tag_start + 4 + relative_end + 3; + continue; + } + let Some(tag_prefix) = value.get(tag_start + 1..tag_start + 4) else { + offset = tag_start + 1; + continue; }; - let tag = &value[tag_start + 1..tag_start + relative_end]; - if let Some(image) = preservable_image_from_img_tag(tag, attachments) - && !images - .iter() - .any(|existing: &PreservableImage| existing.key == image.key) + let boundary = value[tag_start + 4..].chars().next(); + if !tag_prefix.eq_ignore_ascii_case("img") + || !boundary.is_some_and(|character| { + character.is_ascii_whitespace() || character == '/' || character == '>' + }) + { + offset = tag_start + 1; + continue; + } + let Some(tag_end) = find_html_tag_end(value, tag_start + 1) else { + offset = tag_start + 4; + continue; + }; + let tag = &value[tag_start + 1..tag_end]; + if let Some(image) = preservable_image_from_img_tag(tag, attachments, infer_known_paths) + && image_keys.insert(image.key.clone()) { images.push(image); } - offset = tag_start + relative_end + 1; + offset = tag_end + 1; } images } @@ -107,8 +144,9 @@ fn preservable_images_from_html(value: &str, attachments: &[Attachment]) -> Vec< fn preservable_image_from_img_tag( tag: &str, attachments: &[Attachment], + infer_known_paths: bool, ) -> Option { - if let Some(id) = managed_attachment_id_from_img_tag(tag, attachments) { + if let Some(id) = managed_attachment_id_from_img_tag(tag, attachments, infer_known_paths) { let alt = image_alt_from_tag(tag).or_else(|| { attachments .iter() @@ -117,7 +155,6 @@ fn preservable_image_from_img_tag( }); return Some(PreservableImage { key: format!("{MANAGED_ATTACHMENT_PROTOCOL}{id}"), - managed_attachment_id: Some(id.clone()), html: managed_image_html(&id, alt.as_deref().unwrap_or("Attached image")), }); } @@ -128,7 +165,6 @@ fn preservable_image_from_img_tag( } Some(PreservableImage { key: source.clone(), - managed_attachment_id: None, html: image_html( &source, image_alt_from_tag(tag) @@ -142,7 +178,11 @@ fn image_alt_from_tag(tag: &str) -> Option { attribute_value(tag, "alt").filter(|value| !value.trim().is_empty()) } -fn managed_attachment_id_from_img_tag(tag: &str, attachments: &[Attachment]) -> Option { +fn managed_attachment_id_from_img_tag( + tag: &str, + attachments: &[Attachment], + infer_known_paths: bool, +) -> Option { attribute_value(tag, "data-attachment-id") .and_then(|id| clean_managed_attachment_id(&id)) .or_else(|| { @@ -152,21 +192,18 @@ fn managed_attachment_id_from_img_tag(tag: &str, attachments: &[Attachment]) -> .strip_prefix(MANAGED_ATTACHMENT_PROTOCOL) .and_then(clean_managed_attachment_id) .or_else(|| { - attachments - .iter() - .find(|attachment| { - source == attachment.relative_path || source == attachment.filename - }) - .map(|attachment| attachment.id.clone()) + infer_known_paths.then(|| unique_attachment_id(source, attachments))? }) }) }) } -fn contains_managed_attachment(value: &str, attachment_id: &str) -> bool { - value.contains(&format!("data-attachment-id=\"{attachment_id}\"")) - || value.contains(&format!("data-attachment-id='{attachment_id}'")) - || value.contains(&format!("{MANAGED_ATTACHMENT_PROTOCOL}{attachment_id}")) +fn unique_attachment_id(source: &str, attachments: &[Attachment]) -> Option { + let mut matches = attachments + .iter() + .filter(|attachment| source == attachment.relative_path || source == attachment.filename); + let attachment_id = matches.next()?.id.clone(); + matches.next().is_none().then_some(attachment_id) } fn is_preservable_external_image_source(source: &str) -> bool { diff --git a/crates/qa-scribe-core/src/generation/tests/images.rs b/crates/qa-scribe-core/src/generation/tests/images.rs index 4b65a97..365618e 100644 --- a/crates/qa-scribe-core/src/generation/tests/images.rs +++ b/crates/qa-scribe-core/src/generation/tests/images.rs @@ -54,6 +54,88 @@ fn summary_response_leaves_ambiguous_duplicate_filename_images_unrestored() { assert!(preserved.contains("src=\"screenshot.png\"")); } +#[test] +fn summary_response_does_not_treat_variant_or_ambiguous_paths_as_managed_evidence() { + let first = test_attachment("attachment-1", Some("entry-selected"), "screenshot.png"); + let second = test_attachment("attachment-2", Some("entry-selected"), "screenshot.png"); + let original = r#"Evidence"#; + let response = r#"Ambiguous"#; + + let preserved = preserve_managed_attachment_images(response, original, &[first, second]); + + assert!(preserved.contains("src=\"screenshot.png\"")); + assert!(preserved.contains("data-attachment-id=\"attachment-2\"")); + assert!(!preserved.contains("data-attachment-id=\"attachment-1\"")); + assert_eq!(preserved.matches(""#; + let response = r#"Lazy provider image"#; + + let preserved = + preserve_managed_attachment_images(response, original, std::slice::from_ref(&attachment)); + + assert!(!preserved.contains("data-src")); + assert_eq!( + preserved + .matches("data-attachment-id=\"attachment-1\"") + .count(), + 1 + ); + assert_eq!(preserved.matches(""#; + + let preserved = + preserve_managed_attachment_images("

Summary.

", original, &[first, second]); + + assert!(!preserved.contains("qa-scribe-attachment://")); + assert!(!preserved.contains("data-attachment-id")); +} + +#[test] +fn summary_response_restores_only_cross_field_unique_attachment_sources() { + let mut first = test_attachment("attachment-1", Some("entry-selected"), "first.png"); + first.relative_path = "shared.png".to_string(); + let second = test_attachment("attachment-2", Some("entry-selected"), "shared.png"); + let original = r#"Evidence"#; + + let preserved = preserve_managed_attachment_images( + r#"Ambiguous provider path"#, + original, + &[first, second], + ); + + assert!(preserved.contains("src=\"shared.png\"")); + assert_eq!( + preserved + .matches("data-attachment-id=\"attachment-1\"") + .count(), + 1 + ); + assert!(!preserved.contains("data-attachment-id=\"attachment-2\"")); + assert_eq!(preserved.matches("Literal src="evidence.png" text.

"#; + + let preserved = + preserve_managed_attachment_images(response, "", std::slice::from_ref(&attachment)); + + assert_eq!(preserved, response); +} + #[test] fn summary_response_restores_a_unique_filename_even_when_other_attachments_share_a_different_filename() { @@ -114,3 +196,150 @@ fn summary_response_restores_missing_external_images() { 1 ); } + +#[test] +fn summary_response_preserves_quoted_tag_delimiters_in_image_attributes() { + let original = + r#"

Evidence.

Before > after"#; + + let preserved = preserve_managed_attachment_images("

Summary.

", original, &[]); + + assert!(preserved.contains("src=\"https://example.com/comparison.png\"")); + assert!(preserved.contains("alt=\"Before > after\"")); + assert_eq!(preserved.matches("Evidence.

Evidence"#; + + for (response, expected_image_count) in [ + ( + r#"

Mentions qa-scribe-attachment://attachment-1 in text.

"#, + 1, + ), + ( + r#"Evidence link"#, + 1, + ), + ( + r#"

Removed attribute

"#, + 1, + ), + ( + r#"Different image"#, + 2, + ), + ] { + let preserved = preserve_managed_attachment_images( + response, + original, + std::slice::from_ref(&attachment), + ); + assert!( + preserved.contains("data-attachment-id=\"attachment-1\""), + "missing exact Evidence image for {response:?}: {preserved:?}" + ); + assert_eq!( + preserved.matches("Summary.

Updated"#, + original, + &[attachment], + ); + assert_eq!( + exact + .match_indices("data-attachment-id=\"attachment-1\"") + .count(), + 1 + ); + assert_eq!(exact.matches("Evidence.

Evidence"#); + + for response in [ + format!("

Mentions {source} in text.

"), + format!(r#"Evidence link"#), + format!(r#"

Removed attribute

"#), + format!(r#"Different image"#), + ] { + let preserved = preserve_managed_attachment_images(&response, &original, &[]); + assert!( + preserved.contains(&format!("src=\"{source}\"")), + "missing exact external Evidence image for {response:?}: {preserved:?}" + ); + } + + let exact = preserve_managed_attachment_images( + &format!(r#"

Summary.

Updated"#), + &original, + &[], + ); + assert_eq!(exact.matches(""#); + + for response in [ + format!(r#"Not an img tag"#), + format!(r#""#), + format!(r#""#), + ] { + let preserved = preserve_managed_attachment_images(&response, &original, &[]); + assert_eq!( + preserved.matches(&format!("src=\"{source}\"")).count(), + 1, + "the original image should be restored for {response:?}: {preserved:?}" + ); + assert!(preserved.contains(&format!("

\"Evidence\"

"))); + assert_eq!(preserved.matches("

Evidence"#; + let response = r#""#; + + let preserved = + preserve_managed_attachment_images(response, original, std::slice::from_ref(&attachment)); + + assert!(!preserved.contains("script")); + assert_eq!( + preserved + .matches("data-attachment-id=\"attachment-1\"") + .count(), + 1 + ); + assert_eq!(preserved.matches("\"First\"\ + \"unterminated" + ); + + let preserved = preserve_managed_attachment_images("

Summary.

", &original, &[]); + + assert!(preserved.contains(&format!("src=\"{first_source}\""))); + assert!(preserved.contains(&format!("src=\"{second_source}\""))); + assert_eq!(preserved.matches("Before café.

+ Evidence A > B 日本語 + Comparison +

After.

+ "#; + + let projected = project_html_to_prompt_text(html); + + assert!(projected.contains("Before café.")); + assert!(projected.contains("[Image: Evidence A > B 日本語]")); + assert!(projected.contains("Comparison (https://example.test/compare?left=5>3&mode=full)")); + assert!(projected.contains("After.")); + assert!(!projected.contains("data-attachment-id")); +} + +#[test] +fn projection_handles_nested_looking_quoted_content_and_unclosed_tags_without_panicking() { + let nested = project_html_to_prompt_text( + r#"Nested <strong>value > threshold</strong>

After

"#, + ); + assert!(nested.contains("[Image: Nested value > threshold]")); + assert!(nested.contains("After")); + + let malformed = project_html_to_prompt_text( + r#"

Before

unterminated > <script>literal</script><p>After</p>")); + assert!(malformed.contains("literal")); + assert!(malformed.contains("After")); +} + +#[test] +fn projection_quote_scanner_handles_generated_payload_matrix() { + let cases = [ + ("A > B", "A > B"), + ("café > naïve", "café > naïve"), + ("日本語 > ✅", "日本語 > ✅"), + ( + "Nested value > threshold", + "Nested value > threshold", + ), + ("A > B > C", "A > B > C"), + ]; + + for quote in ['"', '\''] { + for (payload, expected) in cases { + let html = format!( + "{quote}{payload}{quote}

After

" + ); + let projected = project_html_to_prompt_text(&html); + + assert!( + projected.contains(&format!("[Image: {expected}]")), + "quote={quote:?}, payload={payload:?}, projected={projected:?}" + ); + assert!(projected.ends_with("After")); + } + } +} + #[test] fn projection_preserves_tiptap_task_list_state() { let html = r#" diff --git a/crates/qa-scribe-core/src/generation/tests/response_parser.rs b/crates/qa-scribe-core/src/generation/tests/response_parser.rs index d0a0b08..6676575 100644 --- a/crates/qa-scribe-core/src/generation/tests/response_parser.rs +++ b/crates/qa-scribe-core/src/generation/tests/response_parser.rs @@ -176,6 +176,116 @@ fn generated_rich_html_uses_valid_managed_src_when_attachment_id_attribute_is_ma assert!(!sanitized.contains("onerror")); } +#[test] +fn generated_rich_html_preserves_quoted_delimiters_and_sanitizes_attributes() { + let sanitized = sanitize_generated_rich_html( + r#"

café 比較

+Comparison +Evidence A > B <script>marker</script>"#, + ); + + assert!(sanitized.contains("

café 比較

")); + assert!(sanitized.contains( + "Comparison" + )); + assert!(sanitized.contains( + "\"Evidence" + )); + assert!(!sanitized.contains("onclick")); + assert!(!sanitized.contains("onmouseover")); + assert!(!sanitized.contains("onerror")); + assert_eq!(sanitized.matches("link"#, + "href=\"https://example.test/a>b\"", + true, + ), + ( + r#"link"#, + "href=\"mailto:qa@example.test?subject=A>B\"", + true, + ), + ( + r#"link"#, + "href=\"/relative/a>b\"", + true, + ), + ( + r#"link"#, + "href=", + false, + ), + (r#"link"#, "href=", false), + ]; + + for (input, expected, allowed) in cases { + let sanitized = sanitize_generated_rich_html(input); + assert_eq!( + sanitized.contains(expected), + allowed, + "unexpected link sanitization for {input:?}: {sanitized:?}" + ); + assert!(sanitized.ends_with("link")); + } + + let images = sanitize_generated_rich_html( + r#"https +inline +relative +unsafe +unsafe data"#, + ); + assert!(images.contains("src=\"https://example.test/a>b.png\"")); + assert!(images.contains("src=\"data:image/png;base64,AA>BB\"")); + assert!(images.contains("src=\"/relative/a>b.png\"")); + assert!(!images.contains("javascript:")); + assert!(!images.contains("data:text/html")); + assert_eq!(images.matches("Before

unclosed > <script>alert(1)</script><p>After</p>Before

<img")); + assert!(sanitized.contains("alt=\"unclosed > ")); + assert!(sanitized.contains("

After

")); + assert!(!sanitized.contains(" B", + "café > naïve", + "日本語 > ✅", + "nested value > boundary", + "> and >", + ]; + + for quote in ['"', '\''] { + for payload in payloads { + let input = format!( + "{quote}{payload}{quote} call{quote}>" + ); + let sanitized = sanitize_generated_rich_html(&input); + + assert_eq!(sanitized.matches(""), "input: {input}"); + } + } +} + #[test] fn project_html_to_prompt_text_still_decodes_typographic_entities() { // In contrast to the repair path above, projecting stored HTML into diff --git a/crates/qa-scribe-core/src/generation/workflow.rs b/crates/qa-scribe-core/src/generation/workflow.rs index 521c26a..42e2011 100644 --- a/crates/qa-scribe-core/src/generation/workflow.rs +++ b/crates/qa-scribe-core/src/generation/workflow.rs @@ -231,7 +231,7 @@ fn finish_successful_generation( if let Some(model) = output.reported_model() { service.record_running_ai_run_model(&prepared.ai_run.id, &model)?; } - let response = output.response_text(); + let response = output.response_text().map_err(QaScribeError::Validation)?; let body = parse_rich_html_fragment_response(&response, &prepared.output_marker); validate_generated_body(&body)?; match request.action { @@ -290,11 +290,19 @@ fn finish_testware_generation( prepared: PreparedGeneration, body: String, ) -> Result { - let body = preserve_managed_attachment_images( - &body, - prepared.selected_note_body.as_deref().unwrap_or_default(), - &prepared.attachments, - ); + let body = if request + .testware_preferences + .as_ref() + .is_none_or(|preferences| preferences.preserve_evidence) + { + preserve_managed_attachment_images( + &body, + prepared.selected_note_body.as_deref().unwrap_or_default(), + &prepared.attachments, + ) + } else { + body + }; let body = sanitize_generated_rich_html(&body); let (ai_run, draft) = service.complete_ai_run_with_generated_draft( &prepared.ai_run.id, diff --git a/crates/qa-scribe-core/src/generation/workflow/tests.rs b/crates/qa-scribe-core/src/generation/workflow/tests.rs index ee9b7c3..73941a4 100644 --- a/crates/qa-scribe-core/src/generation/workflow/tests.rs +++ b/crates/qa-scribe-core/src/generation/workflow/tests.rs @@ -1,5 +1,5 @@ use crate::{ - ai::ProviderGenerationOutput, + ai::{GenerationOutputFormat, ProviderGenerationOutput}, domain::{ AiProvider, Attachment, AttachmentDraft, Entry, EntryDraft, EntryPatch, EntryType, Session, SessionDraft, @@ -63,6 +63,21 @@ fn request_for( } } +fn testware_preferences_with_preserve_evidence( + preserve_evidence: bool, +) -> TestwareGenerationPreferences { + TestwareGenerationPreferences { + technique: TestwareTechnique::Auto, + output_format: TestwareOutputFormat::QaCases, + depth: TestwareDepth::Balanced, + include_negative_cases: true, + include_boundary_cases: true, + include_test_data: true, + preserve_evidence, + custom_instructions: None, + } +} + fn create_session(service: &SessionService, title: &str) -> Session { service .create_session(SessionDraft { @@ -126,6 +141,21 @@ fn success_generation_output(response: &str) -> ProviderGenerationOutput { stdout: response.as_bytes().to_vec(), stderr: Vec::new(), assistant_text: None, + output_format: GenerationOutputFormat::PlainText, + cancelled: false, + } +} + +fn successful_structured_output( + output_format: GenerationOutputFormat, + stdout: &str, +) -> ProviderGenerationOutput { + ProviderGenerationOutput { + exit_success: Some(true), + stdout: stdout.as_bytes().to_vec(), + stderr: Vec::new(), + assistant_text: None, + output_format, cancelled: false, } } diff --git a/crates/qa-scribe-core/src/generation/workflow/tests/completion.rs b/crates/qa-scribe-core/src/generation/workflow/tests/completion.rs index 1eb31ad..d50026b 100644 --- a/crates/qa-scribe-core/src/generation/workflow/tests/completion.rs +++ b/crates/qa-scribe-core/src/generation/workflow/tests/completion.rs @@ -145,6 +145,97 @@ fn testware_completion_preserves_managed_screenshots() { assert!(draft.body.contains("alt=\"Gmail error\"")); } +#[test] +fn testware_preserve_evidence_true_restores_omitted_managed_and_external_images() { + let service = SessionService::in_memory().expect("service should open"); + let session = create_session(&service, "Mixed evidence"); + let (note, attachment) = create_note_with_attachment(&service, &session); + let external_source = "https://evidence.example/source.png"; + let note = service + .update_entry( + ¬e.id, + EntryPatch { + body: Some(format!( + "{}

\"External

", + note.body + )), + ..EntryPatch::default() + }, + ) + .expect("mixed evidence note should update"); + let mut request = request_for(&session.id, GenerateAiActionKind::Testware, Some(¬e.id)); + request.testware_preferences = Some(testware_preferences_with_preserve_evidence(true)); + let prepared = + prepare_ai_action_generation(&service, &request).expect("generation should prepare"); + + let result = finish_ai_action_generation( + &service, + &request, + prepared, + Ok(success_generation_output( + "

Generated cases

No image echoed by provider.

", + )), + ) + .expect("generation should finish"); + let body = result.draft.expect("testware draft").body; + + assert!(body.contains(&format!("src=\"qa-scribe-attachment://{}\"", attachment.id))); + assert!(body.contains(&format!("data-attachment-id=\"{}\"", attachment.id))); + assert!(body.contains(&format!("src=\"{external_source}\""))); + assert!(!body.contains("onerror")); +} + +#[test] +fn testware_preserve_evidence_false_omits_source_images_but_sanitizes_provider_images() { + let service = SessionService::in_memory().expect("service should open"); + let session = create_session(&service, "Mixed evidence"); + let (note, attachment) = create_note_with_attachment(&service, &session); + let external_source = "https://evidence.example/source.png"; + let note = service + .update_entry( + ¬e.id, + EntryPatch { + body: Some(format!( + "{}

\"External

", + note.body + )), + ..EntryPatch::default() + }, + ) + .expect("mixed evidence note should update"); + let mut request = request_for(&session.id, GenerateAiActionKind::Testware, Some(¬e.id)); + request.testware_preferences = Some(testware_preferences_with_preserve_evidence(false)); + let prepared = + prepare_ai_action_generation(&service, &request).expect("generation should prepare"); + + let result = finish_ai_action_generation( + &service, + &request, + prepared, + Ok(success_generation_output( + r#"

Generated cases

+Provider managed +Provider external +Unsafe provider image"#, + )), + ) + .expect("generation should finish"); + let body = result.draft.expect("testware draft").body; + + assert!(!body.contains(&attachment.id)); + assert!(!body.contains(external_source)); + assert!(body.contains( + "\"Provider" + )); + assert!(body.contains( + "\"Provider" + )); + assert!(!body.contains("onclick")); + assert!(!body.contains("onerror")); + assert!(!body.contains("javascript:")); + assert!(!body.contains("Unsafe provider image")); +} + #[test] fn testware_persistence_failure_marks_ai_run_failed_not_completed() { let service = SessionService::in_memory().expect("service should open"); @@ -322,6 +413,7 @@ fn invalid_reported_model_fails_the_run_instead_of_leaving_it_running() { .into_bytes(), stderr: Vec::new(), assistant_text: Some("

Generated output.

".to_string()), + output_format: GenerationOutputFormat::ClaudeStreamJson, cancelled: false, }; diff --git a/crates/qa-scribe-core/src/generation/workflow/tests/summary.rs b/crates/qa-scribe-core/src/generation/workflow/tests/summary.rs index d896e09..68b5392 100644 --- a/crates/qa-scribe-core/src/generation/workflow/tests/summary.rs +++ b/crates/qa-scribe-core/src/generation/workflow/tests/summary.rs @@ -197,3 +197,117 @@ fn summary_completion_rejects_stale_note_overwrite() { "failed" ); } + +#[test] +fn summary_unknown_structured_output_fails_without_replacing_unchanged_note() { + let service = SessionService::in_memory().expect("service should open"); + let session = create_session(&service, "Protocol compatibility"); + let original_body = "

Keep this authored note.

"; + let note = create_note(&service, &session.id, "Selected note", original_body); + let request = request_for(&session.id, GenerateAiActionKind::Summary, Some(¬e.id)); + let prepared = + prepare_ai_action_generation(&service, &request).expect("generation should prepare"); + let ai_run_id = prepared.ai_run.id.clone(); + let raw_protocol = + r#"{"type":"future.response","payload":"

Raw JSON must not become the note.

"}"#; + + let error = finish_ai_action_generation( + &service, + &request, + prepared, + Ok(successful_structured_output( + GenerationOutputFormat::CodexJsonl, + raw_protocol, + )), + ) + .expect_err("unknown structured output should fail"); + + assert!( + error + .to_string() + .contains("no recognized assistant content") + ); + assert!(error.to_string().contains("unsupported event format")); + let current_note = service + .list_entries(&session.id) + .expect("entries should list") + .into_iter() + .find(|entry| entry.id == note.id) + .expect("note still exists"); + assert_eq!(current_note.body, original_body); + let ai_run = service + .get_ai_run(&ai_run_id) + .expect("AI Run should read") + .expect("AI Run should exist"); + assert_eq!(ai_run.status.as_str(), "failed"); + assert!( + !ai_run + .error_message + .as_deref() + .unwrap_or_default() + .contains("Raw JSON") + ); +} + +#[test] +fn summary_malformed_structured_output_reports_compatibility_before_stale_note_error() { + let service = SessionService::in_memory().expect("service should open"); + let session = create_session(&service, "Protocol compatibility"); + let note = create_note( + &service, + &session.id, + "Selected note", + "

Original note.

", + ); + let request = request_for(&session.id, GenerateAiActionKind::Summary, Some(¬e.id)); + let prepared = + prepare_ai_action_generation(&service, &request).expect("generation should prepare"); + let ai_run_id = prepared.ai_run.id.clone(); + service + .update_entry( + ¬e.id, + EntryPatch { + body: Some("

User edited while generation ran.

".to_string()), + ..EntryPatch::default() + }, + ) + .expect("user edit should persist"); + + let error = finish_ai_action_generation( + &service, + &request, + prepared, + Ok(successful_structured_output( + GenerationOutputFormat::ClaudeStreamJson, + r#"{"type":"assistant","message":"unterminated"#, + )), + ) + .expect_err("malformed structured output should fail"); + + assert!( + error + .to_string() + .contains("no recognized assistant content") + ); + assert!(error.to_string().contains("Claude stream-json")); + assert!(!error.to_string().contains("Selected Note changed")); + let current_note = service + .list_entries(&session.id) + .expect("entries should list") + .into_iter() + .find(|entry| entry.id == note.id) + .expect("note still exists"); + assert_eq!( + current_note.body, + "

User edited while generation ran.

" + ); + assert_eq!( + service + .get_ai_run(&ai_run_id) + .expect("AI Run should read") + .expect("AI Run should exist") + .status + .as_str(), + "failed" + ); +} diff --git a/crates/qa-scribe-core/src/services/session_service.rs b/crates/qa-scribe-core/src/services/session_service.rs index 6881b13..3c87ec1 100644 --- a/crates/qa-scribe-core/src/services/session_service.rs +++ b/crates/qa-scribe-core/src/services/session_service.rs @@ -10,6 +10,7 @@ mod drafts; mod entries; mod findings; mod generation; +mod rich_body; mod sessions; mod settings; diff --git a/crates/qa-scribe-core/src/services/session_service/attachments.rs b/crates/qa-scribe-core/src/services/session_service/attachments.rs index 63b3dfe..7ba9a49 100644 --- a/crates/qa-scribe-core/src/services/session_service/attachments.rs +++ b/crates/qa-scribe-core/src/services/session_service/attachments.rs @@ -88,6 +88,41 @@ impl SessionService { .optional() .map_err(Into::into) } + + pub fn delete_attachment(&self, id: &str) -> Result<()> { + let deleted = self + .database + .connection() + .execute("DELETE FROM attachments WHERE id = ?1", [id])?; + if deleted == 0 { + return Err(QaScribeError::NotFound(id.to_string())); + } + Ok(()) + } + + pub fn attachment_is_referenced(&self, id: &str) -> Result { + self.database + .connection() + .query_row( + "SELECT EXISTS( + SELECT 1 FROM entries + WHERE instr(body, ?1) > 0 OR instr(COALESCE(body_json, ''), ?1) > 0 + UNION ALL + SELECT 1 FROM drafts + WHERE instr(body, ?1) > 0 OR instr(COALESCE(body_json, ''), ?1) > 0 + UNION ALL + SELECT 1 FROM findings + WHERE instr(body, ?1) > 0 OR instr(COALESCE(body_json, ''), ?1) > 0 + UNION ALL + SELECT 1 FROM evidence_links WHERE attachment_id = ?1 + UNION ALL + SELECT 1 FROM generation_context_attachments WHERE attachment_id = ?1 + )", + [id], + |row| row.get::<_, bool>(0), + ) + .map_err(Into::into) + } } fn is_safe_relative_path(path: &Path) -> bool { diff --git a/crates/qa-scribe-core/src/services/session_service/drafts.rs b/crates/qa-scribe-core/src/services/session_service/drafts.rs index 0f48be6..c9c6dd7 100644 --- a/crates/qa-scribe-core/src/services/session_service/drafts.rs +++ b/crates/qa-scribe-core/src/services/session_service/drafts.rs @@ -10,7 +10,10 @@ use crate::{ }; use super::super::session_rows::map_draft; -use super::{SessionService, new_id, now, require_row_in_session, require_session}; +use super::{ + SessionService, new_id, now, require_row_in_session, require_session, + rich_body::{ExistingRichBody, ResolvedRichBody, RichBodyPatch, resolve_rich_body_patch}, +}; impl SessionService { pub fn create_draft(&self, draft: DraftCreate) -> Result { @@ -85,26 +88,24 @@ impl SessionService { self.database.with_immediate_tx(|tx| { let existing = draft_by_id(tx, id)?.ok_or_else(|| QaScribeError::NotFound(id.to_string()))?; + let ResolvedRichBody { + body, + body_json, + body_format, + } = resolve_rich_body_patch( + "Draft body", + DRAFT_BODY_MAX_LENGTH, + ExistingRichBody::from(&existing), + RichBodyPatch { + body: patch.body, + body_json: patch.body_json, + body_format: patch.body_format, + }, + )?; let title = match patch.title { Some(title) => validate_required_text("Draft title", &title, TITLE_MAX_LENGTH)?, None => existing.title, }; - let body = match patch.body { - Some(body) => validate_body_text("Draft body", &body, DRAFT_BODY_MAX_LENGTH)?, - None => existing.body, - }; - let body_json = match patch.body_json { - Some(body_json) => validate_body_json(body_json)?, - None => existing.body_json, - }; - let body_format = match patch.body_format { - Some(body_format) => validate_optional_text( - "Draft body format", - body_format, - BODY_FORMAT_MAX_LENGTH, - )?, - None => existing.body_format, - }; let metadata_json = match patch.metadata_json { Some(metadata_json) => validate_metadata_json(metadata_json)?, None => existing.metadata_json, diff --git a/crates/qa-scribe-core/src/services/session_service/entries.rs b/crates/qa-scribe-core/src/services/session_service/entries.rs index 64ffd46..e654761 100644 --- a/crates/qa-scribe-core/src/services/session_service/entries.rs +++ b/crates/qa-scribe-core/src/services/session_service/entries.rs @@ -10,7 +10,10 @@ use crate::{ }; use super::super::session_rows::map_entry; -use super::{SessionService, new_id, now, require_row_in_session, require_session}; +use super::{ + SessionService, new_id, now, require_row_in_session, require_session, + rich_body::{ExistingRichBody, ResolvedRichBody, RichBodyPatch, resolve_rich_body_patch}, +}; impl SessionService { pub fn create_entry(&self, draft: EntryDraft) -> Result { @@ -86,26 +89,24 @@ impl SessionService { self.database.with_immediate_tx(|tx| { let existing = entry(tx, id)?.ok_or_else(|| QaScribeError::NotFound(id.to_string()))?; + let ResolvedRichBody { + body, + body_json, + body_format, + } = resolve_rich_body_patch( + "Entry body", + TEXT_BODY_MAX_LENGTH, + ExistingRichBody::from(&existing), + RichBodyPatch { + body: patch.body, + body_json: patch.body_json, + body_format: patch.body_format, + }, + )?; let title = match patch.title { Some(title) => validate_optional_text("Entry title", title, TITLE_MAX_LENGTH)?, None => existing.title, }; - let body = match patch.body { - Some(body) => validate_body_text("Entry body", &body, TEXT_BODY_MAX_LENGTH)?, - None => existing.body, - }; - let body_json = match patch.body_json { - Some(body_json) => validate_body_json(body_json)?, - None => existing.body_json, - }; - let body_format = match patch.body_format { - Some(body_format) => validate_optional_text( - "Entry body format", - body_format, - BODY_FORMAT_MAX_LENGTH, - )?, - None => existing.body_format, - }; let metadata_json = match patch.metadata_json { Some(metadata_json) => validate_metadata_json(metadata_json)?, None => existing.metadata_json, @@ -140,26 +141,24 @@ impl SessionService { .to_string(), )); } + let ResolvedRichBody { + body, + body_json, + body_format, + } = resolve_rich_body_patch( + "Entry body", + TEXT_BODY_MAX_LENGTH, + ExistingRichBody::from(&existing), + RichBodyPatch { + body: patch.body, + body_json: patch.body_json, + body_format: patch.body_format, + }, + )?; let title = match patch.title { Some(title) => validate_optional_text("Entry title", title, TITLE_MAX_LENGTH)?, None => existing.title, }; - let body = match patch.body { - Some(body) => validate_body_text("Entry body", &body, TEXT_BODY_MAX_LENGTH)?, - None => existing.body, - }; - let body_json = match patch.body_json { - Some(body_json) => validate_body_json(body_json)?, - None => existing.body_json, - }; - let body_format = match patch.body_format { - Some(body_format) => validate_optional_text( - "Entry body format", - body_format, - BODY_FORMAT_MAX_LENGTH, - )?, - None => existing.body_format, - }; let metadata_json = match patch.metadata_json { Some(metadata_json) => validate_metadata_json(metadata_json)?, None => existing.metadata_json, diff --git a/crates/qa-scribe-core/src/services/session_service/findings.rs b/crates/qa-scribe-core/src/services/session_service/findings.rs index c6cb2ef..4656a85 100644 --- a/crates/qa-scribe-core/src/services/session_service/findings.rs +++ b/crates/qa-scribe-core/src/services/session_service/findings.rs @@ -12,7 +12,10 @@ use crate::{ }; use super::super::session_rows::{map_evidence_link, map_finding}; -use super::{SessionService, new_id, now, require_row_in_session, require_session}; +use super::{ + SessionService, new_id, now, require_row_in_session, require_session, + rich_body::{ExistingRichBody, ResolvedRichBody, RichBodyPatch, resolve_rich_body_patch}, +}; impl SessionService { pub fn create_finding(&self, draft: FindingDraft) -> Result { @@ -79,26 +82,24 @@ impl SessionService { self.database.with_immediate_tx(|tx| { let existing = finding(tx, id)?.ok_or_else(|| QaScribeError::NotFound(id.to_string()))?; + let ResolvedRichBody { + body, + body_json, + body_format, + } = resolve_rich_body_patch( + "Finding body", + TEXT_BODY_MAX_LENGTH, + ExistingRichBody::from(&existing), + RichBodyPatch { + body: patch.body, + body_json: patch.body_json, + body_format: patch.body_format, + }, + )?; let title = match patch.title { Some(title) => validate_required_text("Finding title", &title, TITLE_MAX_LENGTH)?, None => existing.title, }; - let body = match patch.body { - Some(body) => validate_body_text("Finding body", &body, TEXT_BODY_MAX_LENGTH)?, - None => existing.body, - }; - let body_json = match patch.body_json { - Some(body_json) => validate_body_json(body_json)?, - None => existing.body_json, - }; - let body_format = match patch.body_format { - Some(body_format) => validate_optional_text( - "Finding body format", - body_format, - BODY_FORMAT_MAX_LENGTH, - )?, - None => existing.body_format, - }; let kind = patch.kind.unwrap_or(existing.kind); let metadata_json = match patch.metadata_json { Some(metadata_json) => validate_metadata_json(metadata_json)?, diff --git a/crates/qa-scribe-core/src/services/session_service/generation.rs b/crates/qa-scribe-core/src/services/session_service/generation.rs index 0793d59..37bb6b7 100644 --- a/crates/qa-scribe-core/src/services/session_service/generation.rs +++ b/crates/qa-scribe-core/src/services/session_service/generation.rs @@ -13,7 +13,10 @@ use crate::{ }; use super::super::session_rows::{map_ai_run, map_draft, map_entry, map_finding}; -use super::{SessionService, new_id, now, require_row_in_session, require_session}; +use super::{ + SessionService, new_id, now, require_row_in_session, require_session, + rich_body::{ExistingRichBody, ResolvedRichBody, RichBodyPatch, resolve_rich_body_patch}, +}; mod context; @@ -242,26 +245,24 @@ impl SessionService { )); } + let ResolvedRichBody { + body, + body_json, + body_format, + } = resolve_rich_body_patch( + "Entry body", + TEXT_BODY_MAX_LENGTH, + ExistingRichBody::from(&existing), + RichBodyPatch { + body: patch.body, + body_json: patch.body_json, + body_format: patch.body_format, + }, + )?; let title = match patch.title { Some(title) => validate_optional_text("Entry title", title, TITLE_MAX_LENGTH)?, None => existing.title, }; - let body = match patch.body { - Some(body) => validate_body_text("Entry body", &body, TEXT_BODY_MAX_LENGTH)?, - None => existing.body, - }; - let body_json = match patch.body_json { - Some(body_json) => validate_body_json(body_json)?, - None => existing.body_json, - }; - let body_format = match patch.body_format { - Some(body_format) => validate_optional_text( - "Entry body format", - body_format, - BODY_FORMAT_MAX_LENGTH, - )?, - None => existing.body_format, - }; let metadata_json = match patch.metadata_json { Some(metadata_json) => validate_metadata_json(metadata_json)?, None => existing.metadata_json, diff --git a/crates/qa-scribe-core/src/services/session_service/rich_body.rs b/crates/qa-scribe-core/src/services/session_service/rich_body.rs new file mode 100644 index 0000000..06cdde3 --- /dev/null +++ b/crates/qa-scribe-core/src/services/session_service/rich_body.rs @@ -0,0 +1,72 @@ +use crate::{ + Result, + domain::{ + BODY_FORMAT_MAX_LENGTH, Draft, Entry, Finding, validate_body_json, validate_body_text, + validate_optional_text, + }, +}; + +pub(super) struct ExistingRichBody<'a> { + pub body: &'a str, + pub body_json: Option<&'a str>, + pub body_format: Option<&'a str>, +} + +macro_rules! existing_rich_body_from { + ($record:ty) => { + impl<'a> From<&'a $record> for ExistingRichBody<'a> { + fn from(record: &'a $record) -> Self { + Self { + body: &record.body, + body_json: record.body_json.as_deref(), + body_format: record.body_format.as_deref(), + } + } + } + }; +} + +existing_rich_body_from!(Entry); +existing_rich_body_from!(Draft); +existing_rich_body_from!(Finding); + +pub(super) struct RichBodyPatch { + pub body: Option, + pub body_json: Option>, + pub body_format: Option>, +} + +pub(super) struct ResolvedRichBody { + pub body: String, + pub body_json: Option, + pub body_format: Option, +} + +pub(super) fn resolve_rich_body_patch( + body_label: &str, + body_max_length: usize, + existing: ExistingRichBody<'_>, + patch: RichBodyPatch, +) -> Result { + let body = match patch.body { + Some(body) => validate_body_text(body_label, &body, body_max_length)?, + None => existing.body.to_string(), + }; + let body_json = match patch.body_json { + Some(body_json) => validate_body_json(body_json)?, + None => existing.body_json.map(str::to_string), + }; + let body_format_label = format!("{body_label} format"); + let body_format = match patch.body_format { + Some(body_format) => { + validate_optional_text(&body_format_label, body_format, BODY_FORMAT_MAX_LENGTH)? + } + None => existing.body_format.map(str::to_string), + }; + + Ok(ResolvedRichBody { + body, + body_json, + body_format, + }) +} diff --git a/crates/qa-scribe-core/tests/session_storage.rs b/crates/qa-scribe-core/tests/session_storage.rs index 4e36fef..09d68c5 100644 --- a/crates/qa-scribe-core/tests/session_storage.rs +++ b/crates/qa-scribe-core/tests/session_storage.rs @@ -1,9 +1,10 @@ use qa_scribe_core::{ QaScribeError, attachments::{ - attachment_file_bytes, attachment_preview_data_url, delete_session_attachment_files, - delete_session_with_attachment_files, import_clipboard_screenshot_data_url, - import_managed_attachment, reconcile_attachment_files, + attachment_file_bytes, attachment_preview_data_url, delete_attachment_with_file, + delete_session_attachment_files, delete_session_with_attachment_files, + import_clipboard_screenshot_data_url, import_managed_attachment, + reconcile_attachment_files, }, domain::{ AiProvider, AiRunCreate, AppSettings, DraftCreate, DraftKind, DraftPatch, EntryDraft, diff --git a/crates/qa-scribe-core/tests/session_storage/attachments.rs b/crates/qa-scribe-core/tests/session_storage/attachments.rs index 6581734..36e7ee3 100644 --- a/crates/qa-scribe-core/tests/session_storage/attachments.rs +++ b/crates/qa-scribe-core/tests/session_storage/attachments.rs @@ -109,376 +109,143 @@ fn managed_attachments_preview_generation_context_and_evidence_flow() { } #[test] -fn create_attachment_rejects_sha256_that_is_not_exactly_64_lowercase_hex_chars() { - let service = SessionService::in_memory().expect("in-memory service should open"); - let session = service - .create_session(SessionDraft { - title: "Attachment sha256 validation".to_string(), - ..SessionDraft::default() - }) - .expect("session should be created"); - - let base_draft = qa_scribe_core::domain::AttachmentDraft { - session_id: session.id.clone(), - entry_id: None, - filename: "evidence.txt".to_string(), - mime_type: None, - size_bytes: 4, - sha256: String::new(), - relative_path: format!("attachments/{}/evidence.txt", session.id), - }; - - let too_short = "a".repeat(63); - let too_long = "a".repeat(65); - let uppercase = "A".repeat(64); - let non_hex = format!("{}g", "a".repeat(63)); - - for invalid_sha256 in [too_short, too_long, uppercase, non_hex] { - let result = service.create_attachment(qa_scribe_core::domain::AttachmentDraft { - sha256: invalid_sha256.clone(), - ..base_draft.clone() - }); - assert!( - result.is_err(), - "sha256 {invalid_sha256:?} must be rejected as invalid" - ); - } - - let valid = service.create_attachment(qa_scribe_core::domain::AttachmentDraft { - sha256: "a".repeat(64), - ..base_draft - }); - assert!(valid.is_ok(), "a well-formed 64-char lowercase hex sha256 should be accepted"); -} - -#[test] -fn create_attachment_rejects_unsafe_relative_paths() { - let service = SessionService::in_memory().expect("in-memory service should open"); - let session = service - .create_session(SessionDraft { - title: "Attachment path validation".to_string(), - ..SessionDraft::default() - }) - .expect("session should be created"); - - let result = service.create_attachment(qa_scribe_core::domain::AttachmentDraft { - session_id: session.id, - entry_id: None, - filename: "evidence.txt".to_string(), - mime_type: None, - size_bytes: 4, - sha256: "a".repeat(64), - relative_path: "attachments/../secret.txt".to_string(), - }); - - assert!(result.is_err(), "unsafe relative paths must be rejected for every core caller"); -} - -#[test] -fn create_attachment_rejects_paths_outside_the_session_attachment_directory() { - let service = SessionService::in_memory().expect("in-memory service should open"); - let session = service - .create_session(SessionDraft { - title: "Attachment path ownership".to_string(), - ..SessionDraft::default() - }) - .expect("session should be created"); - - let result = service.create_attachment(qa_scribe_core::domain::AttachmentDraft { - session_id: session.id.clone(), - entry_id: None, - filename: "evidence.txt".to_string(), - mime_type: None, - size_bytes: 4, - sha256: "a".repeat(64), - relative_path: "attachments/other-session/evidence.txt".to_string(), - }); - - assert!( - result.is_err(), - "core callers must use the managed attachments// path" - ); -} - -#[test] -fn create_attachment_returns_not_found_for_missing_entry() { - let service = SessionService::in_memory().expect("in-memory service should open"); - let session = service - .create_session(SessionDraft { - title: "Attachment not-found check".to_string(), - ..SessionDraft::default() - }) - .expect("session should be created"); - - assert!( - matches!( - service.create_attachment(qa_scribe_core::domain::AttachmentDraft { - session_id: session.id.clone(), - entry_id: Some("missing-entry".to_string()), - filename: "evidence.txt".to_string(), - mime_type: None, - size_bytes: 4, - sha256: "abc123".to_string(), - relative_path: format!("attachments/{}/evidence.txt", session.id), - }), - Err(QaScribeError::NotFound(id)) if id == "missing-entry" - ), - "a missing Entry reference must surface as NotFound, not a raw Sqlite error" - ); -} - -#[test] -fn import_managed_attachment_returns_not_found_for_missing_entry() { +fn imported_attachment_cleanup_removes_its_row_and_managed_file() { let service = SessionService::in_memory().expect("in-memory service should open"); let temp_dir = unique_temp_dir(); fs::create_dir_all(&temp_dir).expect("temp dir should be created"); - let source_path = temp_dir.join("evidence.txt"); - fs::write(&source_path, "evidence").expect("source attachment should write"); - let session = service .create_session(SessionDraft { - title: "Import not-found check".to_string(), + title: "Attachment cleanup".to_string(), ..SessionDraft::default() }) .expect("session should be created"); + let attachment = import_clipboard_screenshot_data_url( + &service, + &temp_dir, + &session.id, + None, + "discarded.png".to_string(), + "data:image/png;base64,aGVsbG8=", + ) + .expect("clipboard screenshot should import"); + let attachment_path = temp_dir.join(&attachment.relative_path); + assert!(attachment_path.is_file()); assert!( - matches!( - import_managed_attachment( - &service, - &temp_dir, - &session.id, - Some("missing-entry".to_string()), - &source_path, - ), - Err(QaScribeError::NotFound(id)) if id == "missing-entry" - ), - "a missing Entry reference must surface as NotFound, not a raw Sqlite error" + delete_attachment_with_file(&service, &temp_dir, &attachment.id) + .expect("attachment should be deleted") ); - fs::remove_dir_all(temp_dir).expect("temp dir should be removed"); -} - -#[test] -fn attachment_file_bytes_fails_integrity_check_when_file_is_corrupted_on_disk() { - let service = SessionService::in_memory().expect("in-memory service should open"); - let temp_dir = unique_temp_dir(); - fs::create_dir_all(&temp_dir).expect("temp dir should be created"); - let source_path = temp_dir.join("evidence.txt"); - fs::write(&source_path, "original evidence bytes").expect("source attachment should write"); - - let session = service - .create_session(SessionDraft { - title: "Attachment integrity check".to_string(), - ..SessionDraft::default() - }) - .expect("session should be created"); - let attachment = import_managed_attachment(&service, &temp_dir, &session.id, None, &source_path) - .expect("attachment should import"); - - // Sanity check: reading back before corruption succeeds and returns the original bytes. - let (_, bytes) = attachment_file_bytes(&service, &temp_dir, &attachment.id) - .expect("attachment read should succeed before corruption") - .expect("attachment should exist"); - assert_eq!(bytes, b"original evidence bytes"); - - fs::write(temp_dir.join(&attachment.relative_path), "corrupted bytes on disk") - .expect("attachment file should be overwritten to simulate corruption"); - - let result = attachment_file_bytes(&service, &temp_dir, &attachment.id); assert!( - matches!(result, Err(QaScribeError::InvalidStoredValue { .. })), - "a corrupted attachment file must fail with a distinct integrity error, got {result:?}" + service + .get_attachment(&attachment.id) + .expect("attachment lookup should succeed") + .is_none() ); - - let preview_result = attachment_preview_data_url(&service, &temp_dir, &attachment.id); + assert!(!attachment_path.exists()); assert!( - matches!(preview_result, Err(QaScribeError::InvalidStoredValue { .. })), - "attachment_preview_data_url must also surface the integrity error, got {preview_result:?}" + delete_attachment_with_file(&service, &temp_dir, &attachment.id) + .expect("repeated cleanup should be idempotent") ); - fs::remove_dir_all(temp_dir).expect("temp dir should be removed"); } #[test] -fn import_rejects_a_filename_too_long_to_fit_the_on_disk_uuid_prefixed_name() { +fn imported_attachment_cleanup_keeps_referenced_content() { let service = SessionService::in_memory().expect("in-memory service should open"); let temp_dir = unique_temp_dir(); fs::create_dir_all(&temp_dir).expect("temp dir should be created"); - let session = service .create_session(SessionDraft { - title: "Attachment filename length check".to_string(), + title: "Referenced attachment cleanup".to_string(), ..SessionDraft::default() }) .expect("session should be created"); - - // The on-disk name is `{uuid}_{filename}` (uuid is 36 chars + `_`), so an - // overlong filename must be rejected with a clean validation error before - // any file touches disk, not surfaced as a raw ENAMETOOLONG I/O error. - let overlong_filename = format!("{}.png", "a".repeat(300)); - let result = import_clipboard_screenshot_data_url( + let attachment = import_clipboard_screenshot_data_url( &service, &temp_dir, &session.id, None, - overlong_filename, + "referenced.png".to_string(), "data:image/png;base64,aGVsbG8=", - ); + ) + .expect("clipboard screenshot should import"); + service + .create_entry(EntryDraft { + session_id: session.id.clone(), + entry_type: EntryType::Note, + title: Some("Note body".to_string()), + body: format!( + "

", + attachment.id, attachment.id + ), + body_json: None, + body_format: Some("html".to_string()), + metadata_json: None, + excluded_from_generation: false, + }) + .expect("referencing Entry should be created"); assert!( - matches!(result, Err(QaScribeError::Validation(_))), - "an overlong filename must fail validation cleanly, got {result:?}" - ); - assert_eq!( - count_rows(service.database().connection(), "attachments"), - 0, - "no attachment row should be created for a rejected filename" + !delete_attachment_with_file(&service, &temp_dir, &attachment.id) + .expect("referenced attachment cleanup should be deferred") ); - - let session_dir = temp_dir.join("attachments").join(&session.id); assert!( - !session_dir.exists() || fs::read_dir(&session_dir).expect("dir should list").next().is_none(), - "no file should be left on disk for a rejected filename" + service + .get_attachment(&attachment.id) + .expect("attachment lookup should succeed") + .is_some() ); - + assert!(temp_dir.join(&attachment.relative_path).is_file()); fs::remove_dir_all(temp_dir).expect("temp dir should be removed"); } #[test] -fn attachment_reconciliation_reports_missing_and_stray_files() { +fn imported_attachment_cleanup_keeps_row_when_file_removal_fails() { let service = SessionService::in_memory().expect("in-memory service should open"); let temp_dir = unique_temp_dir(); fs::create_dir_all(&temp_dir).expect("temp dir should be created"); - let source_path = temp_dir.join("screen.png"); - fs::write(&source_path, "image bytes").expect("source attachment should write"); - let session = service .create_session(SessionDraft { - title: "Attachment reconciliation".to_string(), + title: "Retryable attachment cleanup".to_string(), ..SessionDraft::default() }) .expect("session should be created"); - let attachment = import_managed_attachment(&service, &temp_dir, &session.id, None, &source_path) - .expect("attachment should import"); - - let clean_report = - reconcile_attachment_files(&service, &temp_dir).expect("attachments should reconcile"); - assert!(clean_report.missing_files.is_empty()); - assert!(clean_report.stray_files.is_empty()); - - fs::remove_file(temp_dir.join(&attachment.relative_path)).expect("managed file should remove"); - let stray_path = temp_dir - .join("attachments") - .join(&session.id) - .join("stray.log"); - fs::write(&stray_path, "orphaned").expect("stray file should write"); - - let report = - reconcile_attachment_files(&service, &temp_dir).expect("attachments should reconcile"); - assert_eq!(report.missing_files, vec![attachment.relative_path]); - assert_eq!( - report.stray_files, - vec![format!("attachments/{}/stray.log", session.id)] - ); - - fs::remove_dir_all(temp_dir).expect("temp dir should be removed"); -} - -#[test] -fn delete_session_with_attachment_files_removes_database_rows_and_files() { - let service = SessionService::in_memory().expect("in-memory service should open"); - let temp_dir = unique_temp_dir(); - fs::create_dir_all(&temp_dir).expect("temp dir should be created"); - let source_path = temp_dir.join("evidence.txt"); - fs::write(&source_path, "evidence").expect("source attachment should write"); + let attachment = import_clipboard_screenshot_data_url( + &service, + &temp_dir, + &session.id, + None, + "retry.png".to_string(), + "data:image/png;base64,aGVsbG8=", + ) + .expect("clipboard screenshot should import"); + let attachment_path = temp_dir.join(&attachment.relative_path); + fs::remove_file(&attachment_path).expect("attachment file should be removed"); + fs::create_dir(&attachment_path).expect("directory should replace attachment file"); - let session = service - .create_session(SessionDraft { - title: "Delete with files".to_string(), - ..SessionDraft::default() - }) - .expect("session should be created"); - import_managed_attachment(&service, &temp_dir, &session.id, None, &source_path) - .expect("attachment should import"); + delete_attachment_with_file(&service, &temp_dir, &attachment.id) + .expect_err("directory removal through remove_file should fail"); - let session_dir = temp_dir.join("attachments").join(&session.id); - assert!(session_dir.exists()); - delete_session_with_attachment_files(&service, &temp_dir, &session.id) - .expect("session and attachment files should delete"); - assert!(!session_dir.exists()); assert!( service - .get_session(&session.id) - .expect("session query should work") - .is_none() - ); - assert_eq!( - count_rows(service.database().connection(), "attachments"), - 0 - ); - - fs::remove_dir_all(temp_dir).expect("temp dir should be removed"); -} - -#[test] -fn delete_session_with_attachment_files_keeps_files_when_db_delete_fails() { - let temp_dir = unique_temp_dir(); - fs::create_dir_all(&temp_dir).expect("temp dir should be created"); - let db_path = temp_dir.join("qa-scribe.sqlite"); - let service = SessionService::new(Database::open(&db_path).expect("file-backed database should open")) - .expect("session service should construct"); - let source_path = temp_dir.join("evidence.txt"); - fs::write(&source_path, "evidence").expect("source attachment should write"); - - let session = service - .create_session(SessionDraft { - title: "Delete order safety".to_string(), - ..SessionDraft::default() - }) - .expect("session should be created"); - import_managed_attachment(&service, &temp_dir, &session.id, None, &source_path) - .expect("attachment should import"); - - let session_dir = temp_dir.join("attachments").join(&session.id); - assert!(session_dir.exists()); - - // Force delete_session's UPDATE/DELETE to fail with SQLITE_BUSY by holding - // a write transaction open on a second connection to the same file, and - // shrinking the busy_timeout so the test does not wait out the default. - service - .database() - .connection() - .pragma_update(None, "busy_timeout", 50) - .expect("busy_timeout should update"); - let blocker = rusqlite::Connection::open(&db_path).expect("blocking connection should open"); - blocker - .execute_batch("BEGIN IMMEDIATE; DELETE FROM sessions WHERE id = 'unrelated';") - .expect("blocking transaction should start"); - - let result = delete_session_with_attachment_files(&service, &temp_dir, &session.id); - assert!( - result.is_err(), - "delete should surface the DB failure instead of pretending to succeed" + .get_attachment(&attachment.id) + .expect("attachment lookup should succeed") + .is_some() ); - - blocker - .execute_batch("ROLLBACK;") - .expect("blocking transaction should release"); - - // The critical assertion: because the DB delete happens BEFORE file - // cleanup, a DB failure must leave the evidence files untouched on disk. + fs::remove_dir(&attachment_path).expect("blocking directory should be removed"); assert!( - session_dir.exists(), - "attachment files must survive a failed DB delete, not be destroyed before it runs" + delete_attachment_with_file(&service, &temp_dir, &attachment.id) + .expect("cleanup should retry after the filesystem recovers") ); assert!( service - .get_session(&session.id) - .expect("session query should work") - .is_some(), - "session row must still exist after the failed delete" + .get_attachment(&attachment.id) + .expect("attachment lookup should succeed") + .is_none() ); - fs::remove_dir_all(temp_dir).expect("temp dir should be removed"); } + +include!("attachments/validation_and_integrity.rs"); +include!("attachments/reconciliation_and_session_deletion.rs"); diff --git a/crates/qa-scribe-core/tests/session_storage/attachments/reconciliation_and_session_deletion.rs b/crates/qa-scribe-core/tests/session_storage/attachments/reconciliation_and_session_deletion.rs new file mode 100644 index 0000000..b3aed43 --- /dev/null +++ b/crates/qa-scribe-core/tests/session_storage/attachments/reconciliation_and_session_deletion.rs @@ -0,0 +1,166 @@ +#[test] +fn attachment_reconciliation_reports_missing_and_stray_files() { + let service = SessionService::in_memory().expect("in-memory service should open"); + let temp_dir = unique_temp_dir(); + fs::create_dir_all(&temp_dir).expect("temp dir should be created"); + let source_path = temp_dir.join("screen.png"); + fs::write(&source_path, "image bytes").expect("source attachment should write"); + + let session = service + .create_session(SessionDraft { + title: "Attachment reconciliation".to_string(), + ..SessionDraft::default() + }) + .expect("session should be created"); + let attachment = import_managed_attachment(&service, &temp_dir, &session.id, None, &source_path) + .expect("attachment should import"); + + let clean_report = + reconcile_attachment_files(&service, &temp_dir).expect("attachments should reconcile"); + assert!(clean_report.missing_files.is_empty()); + assert!(clean_report.stray_files.is_empty()); + + fs::remove_file(temp_dir.join(&attachment.relative_path)).expect("managed file should remove"); + let stray_path = temp_dir + .join("attachments") + .join(&session.id) + .join("stray.log"); + fs::write(&stray_path, "orphaned").expect("stray file should write"); + + let report = + reconcile_attachment_files(&service, &temp_dir).expect("attachments should reconcile"); + assert_eq!(report.missing_files, vec![attachment.relative_path]); + assert_eq!( + report.stray_files, + vec![format!("attachments/{}/stray.log", session.id)] + ); + + fs::remove_dir_all(temp_dir).expect("temp dir should be removed"); +} + +#[test] +fn delete_session_with_attachment_files_removes_database_rows_and_files() { + let service = SessionService::in_memory().expect("in-memory service should open"); + let temp_dir = unique_temp_dir(); + fs::create_dir_all(&temp_dir).expect("temp dir should be created"); + let source_path = temp_dir.join("evidence.txt"); + fs::write(&source_path, "evidence").expect("source attachment should write"); + + let session = service + .create_session(SessionDraft { + title: "Delete with files".to_string(), + ..SessionDraft::default() + }) + .expect("session should be created"); + import_managed_attachment(&service, &temp_dir, &session.id, None, &source_path) + .expect("attachment should import"); + + let session_dir = temp_dir.join("attachments").join(&session.id); + assert!(session_dir.exists()); + delete_session_with_attachment_files(&service, &temp_dir, &session.id) + .expect("session and attachment files should delete"); + assert!(!session_dir.exists()); + assert!( + service + .get_session(&session.id) + .expect("session query should work") + .is_none() + ); + assert_eq!( + count_rows(service.database().connection(), "attachments"), + 0 + ); + + fs::remove_dir_all(temp_dir).expect("temp dir should be removed"); +} + +#[test] +fn delete_session_with_attachment_files_succeeds_when_file_cleanup_fails_after_db_delete() { + let service = SessionService::in_memory().expect("in-memory service should open"); + let temp_dir = unique_temp_dir(); + fs::create_dir_all(temp_dir.join("attachments")).expect("attachment root should be created"); + let session = service + .create_session(SessionDraft { + title: "Delete with cleanup residue".to_string(), + ..SessionDraft::default() + }) + .expect("session should be created"); + let session_path = temp_dir.join("attachments").join(&session.id); + fs::write(&session_path, "not a directory").expect("blocking file should be written"); + + delete_session_with_attachment_files(&service, &temp_dir, &session.id) + .expect("durable session deletion should not be reported as failed"); + + assert!( + service + .get_session(&session.id) + .expect("session query should work") + .is_none(), + "session row should remain deleted when best-effort file cleanup fails" + ); + assert!(session_path.exists(), "cleanup residue should remain available for reconciliation"); + + fs::remove_dir_all(temp_dir).expect("temp dir should be removed"); +} + +#[test] +fn delete_session_with_attachment_files_keeps_files_when_db_delete_fails() { + let temp_dir = unique_temp_dir(); + fs::create_dir_all(&temp_dir).expect("temp dir should be created"); + let db_path = temp_dir.join("qa-scribe.sqlite"); + let service = SessionService::new(Database::open(&db_path).expect("file-backed database should open")) + .expect("session service should construct"); + let source_path = temp_dir.join("evidence.txt"); + fs::write(&source_path, "evidence").expect("source attachment should write"); + + let session = service + .create_session(SessionDraft { + title: "Delete order safety".to_string(), + ..SessionDraft::default() + }) + .expect("session should be created"); + import_managed_attachment(&service, &temp_dir, &session.id, None, &source_path) + .expect("attachment should import"); + + let session_dir = temp_dir.join("attachments").join(&session.id); + assert!(session_dir.exists()); + + // Force delete_session's UPDATE/DELETE to fail with SQLITE_BUSY by holding + // a write transaction open on a second connection to the same file, and + // shrinking the busy_timeout so the test does not wait out the default. + service + .database() + .connection() + .pragma_update(None, "busy_timeout", 50) + .expect("busy_timeout should update"); + let blocker = rusqlite::Connection::open(&db_path).expect("blocking connection should open"); + blocker + .execute_batch("BEGIN IMMEDIATE; DELETE FROM sessions WHERE id = 'unrelated';") + .expect("blocking transaction should start"); + + let result = delete_session_with_attachment_files(&service, &temp_dir, &session.id); + assert!( + result.is_err(), + "delete should surface the DB failure instead of pretending to succeed" + ); + + blocker + .execute_batch("ROLLBACK;") + .expect("blocking transaction should release"); + + // The critical assertion: because the DB delete happens BEFORE file + // cleanup, a DB failure must leave the evidence files untouched on disk. + assert!( + session_dir.exists(), + "attachment files must survive a failed DB delete, not be destroyed before it runs" + ); + assert!( + service + .get_session(&session.id) + .expect("session query should work") + .is_some(), + "session row must still exist after the failed delete" + ); + + fs::remove_dir_all(temp_dir).expect("temp dir should be removed"); +} diff --git a/crates/qa-scribe-core/tests/session_storage/attachments/validation_and_integrity.rs b/crates/qa-scribe-core/tests/session_storage/attachments/validation_and_integrity.rs new file mode 100644 index 0000000..181e0da --- /dev/null +++ b/crates/qa-scribe-core/tests/session_storage/attachments/validation_and_integrity.rs @@ -0,0 +1,236 @@ +#[test] +fn create_attachment_rejects_sha256_that_is_not_exactly_64_lowercase_hex_chars() { + let service = SessionService::in_memory().expect("in-memory service should open"); + let session = service + .create_session(SessionDraft { + title: "Attachment sha256 validation".to_string(), + ..SessionDraft::default() + }) + .expect("session should be created"); + + let base_draft = qa_scribe_core::domain::AttachmentDraft { + session_id: session.id.clone(), + entry_id: None, + filename: "evidence.txt".to_string(), + mime_type: None, + size_bytes: 4, + sha256: String::new(), + relative_path: format!("attachments/{}/evidence.txt", session.id), + }; + + let too_short = "a".repeat(63); + let too_long = "a".repeat(65); + let uppercase = "A".repeat(64); + let non_hex = format!("{}g", "a".repeat(63)); + + for invalid_sha256 in [too_short, too_long, uppercase, non_hex] { + let result = service.create_attachment(qa_scribe_core::domain::AttachmentDraft { + sha256: invalid_sha256.clone(), + ..base_draft.clone() + }); + assert!( + result.is_err(), + "sha256 {invalid_sha256:?} must be rejected as invalid" + ); + } + + let valid = service.create_attachment(qa_scribe_core::domain::AttachmentDraft { + sha256: "a".repeat(64), + ..base_draft + }); + assert!(valid.is_ok(), "a well-formed 64-char lowercase hex sha256 should be accepted"); +} + +#[test] +fn create_attachment_rejects_unsafe_relative_paths() { + let service = SessionService::in_memory().expect("in-memory service should open"); + let session = service + .create_session(SessionDraft { + title: "Attachment path validation".to_string(), + ..SessionDraft::default() + }) + .expect("session should be created"); + + let result = service.create_attachment(qa_scribe_core::domain::AttachmentDraft { + session_id: session.id, + entry_id: None, + filename: "evidence.txt".to_string(), + mime_type: None, + size_bytes: 4, + sha256: "a".repeat(64), + relative_path: "attachments/../secret.txt".to_string(), + }); + + assert!(result.is_err(), "unsafe relative paths must be rejected for every core caller"); +} + +#[test] +fn create_attachment_rejects_paths_outside_the_session_attachment_directory() { + let service = SessionService::in_memory().expect("in-memory service should open"); + let session = service + .create_session(SessionDraft { + title: "Attachment path ownership".to_string(), + ..SessionDraft::default() + }) + .expect("session should be created"); + + let result = service.create_attachment(qa_scribe_core::domain::AttachmentDraft { + session_id: session.id.clone(), + entry_id: None, + filename: "evidence.txt".to_string(), + mime_type: None, + size_bytes: 4, + sha256: "a".repeat(64), + relative_path: "attachments/other-session/evidence.txt".to_string(), + }); + + assert!( + result.is_err(), + "core callers must use the managed attachments// path" + ); +} + +#[test] +fn create_attachment_returns_not_found_for_missing_entry() { + let service = SessionService::in_memory().expect("in-memory service should open"); + let session = service + .create_session(SessionDraft { + title: "Attachment not-found check".to_string(), + ..SessionDraft::default() + }) + .expect("session should be created"); + + assert!( + matches!( + service.create_attachment(qa_scribe_core::domain::AttachmentDraft { + session_id: session.id.clone(), + entry_id: Some("missing-entry".to_string()), + filename: "evidence.txt".to_string(), + mime_type: None, + size_bytes: 4, + sha256: "abc123".to_string(), + relative_path: format!("attachments/{}/evidence.txt", session.id), + }), + Err(QaScribeError::NotFound(id)) if id == "missing-entry" + ), + "a missing Entry reference must surface as NotFound, not a raw Sqlite error" + ); +} + +#[test] +fn import_managed_attachment_returns_not_found_for_missing_entry() { + let service = SessionService::in_memory().expect("in-memory service should open"); + let temp_dir = unique_temp_dir(); + fs::create_dir_all(&temp_dir).expect("temp dir should be created"); + let source_path = temp_dir.join("evidence.txt"); + fs::write(&source_path, "evidence").expect("source attachment should write"); + + let session = service + .create_session(SessionDraft { + title: "Import not-found check".to_string(), + ..SessionDraft::default() + }) + .expect("session should be created"); + + assert!( + matches!( + import_managed_attachment( + &service, + &temp_dir, + &session.id, + Some("missing-entry".to_string()), + &source_path, + ), + Err(QaScribeError::NotFound(id)) if id == "missing-entry" + ), + "a missing Entry reference must surface as NotFound, not a raw Sqlite error" + ); + + fs::remove_dir_all(temp_dir).expect("temp dir should be removed"); +} + +#[test] +fn attachment_file_bytes_fails_integrity_check_when_file_is_corrupted_on_disk() { + let service = SessionService::in_memory().expect("in-memory service should open"); + let temp_dir = unique_temp_dir(); + fs::create_dir_all(&temp_dir).expect("temp dir should be created"); + let source_path = temp_dir.join("evidence.txt"); + fs::write(&source_path, "original evidence bytes").expect("source attachment should write"); + + let session = service + .create_session(SessionDraft { + title: "Attachment integrity check".to_string(), + ..SessionDraft::default() + }) + .expect("session should be created"); + let attachment = import_managed_attachment(&service, &temp_dir, &session.id, None, &source_path) + .expect("attachment should import"); + + // Sanity check: reading back before corruption succeeds and returns the original bytes. + let (_, bytes) = attachment_file_bytes(&service, &temp_dir, &attachment.id) + .expect("attachment read should succeed before corruption") + .expect("attachment should exist"); + assert_eq!(bytes, b"original evidence bytes"); + + fs::write(temp_dir.join(&attachment.relative_path), "corrupted bytes on disk") + .expect("attachment file should be overwritten to simulate corruption"); + + let result = attachment_file_bytes(&service, &temp_dir, &attachment.id); + assert!( + matches!(result, Err(QaScribeError::InvalidStoredValue { .. })), + "a corrupted attachment file must fail with a distinct integrity error, got {result:?}" + ); + + let preview_result = attachment_preview_data_url(&service, &temp_dir, &attachment.id); + assert!( + matches!(preview_result, Err(QaScribeError::InvalidStoredValue { .. })), + "attachment_preview_data_url must also surface the integrity error, got {preview_result:?}" + ); + + fs::remove_dir_all(temp_dir).expect("temp dir should be removed"); +} + +#[test] +fn import_rejects_a_filename_too_long_to_fit_the_on_disk_uuid_prefixed_name() { + let service = SessionService::in_memory().expect("in-memory service should open"); + let temp_dir = unique_temp_dir(); + fs::create_dir_all(&temp_dir).expect("temp dir should be created"); + + let session = service + .create_session(SessionDraft { + title: "Attachment filename length check".to_string(), + ..SessionDraft::default() + }) + .expect("session should be created"); + + // The on-disk name is `{uuid}_{filename}` (uuid is 36 chars + `_`), so an + // overlong filename must be rejected with a clean validation error before + // any file touches disk, not surfaced as a raw ENAMETOOLONG I/O error. + let overlong_filename = format!("{}.png", "a".repeat(300)); + let result = import_clipboard_screenshot_data_url( + &service, + &temp_dir, + &session.id, + None, + overlong_filename, + "data:image/png;base64,aGVsbG8=", + ); + + assert!( + matches!(result, Err(QaScribeError::Validation(_))), + "an overlong filename must fail validation cleanly, got {result:?}" + ); + assert_eq!( + count_rows(service.database().connection(), "attachments"), + 0, + "no attachment row should be created for a rejected filename" + ); + + let session_dir = temp_dir.join("attachments").join(&session.id); + assert!( + !session_dir.exists() || fs::read_dir(&session_dir).expect("dir should list").next().is_none(), + "no file should be left on disk for a rejected filename" + ); + + fs::remove_dir_all(temp_dir).expect("temp dir should be removed"); +} diff --git a/crates/qa-scribe-core/tests/session_storage/generation_and_relationships/ai_and_drafts.rs b/crates/qa-scribe-core/tests/session_storage/generation_and_relationships/ai_and_drafts.rs index 9e6f3ac..d7a848c 100644 --- a/crates/qa-scribe-core/tests/session_storage/generation_and_relationships/ai_and_drafts.rs +++ b/crates/qa-scribe-core/tests/session_storage/generation_and_relationships/ai_and_drafts.rs @@ -9,7 +9,11 @@ fn settings_generation_context_ai_run_and_draft_round_trip() { default_settings.generation_system_prompt, default_generation_system_prompt() ); - assert!(!default_settings.generation_system_prompt.contains("Testware")); + assert!( + !default_settings + .generation_system_prompt + .contains("Testware") + ); let updated_settings = service .update_settings(AppSettings { @@ -83,7 +87,10 @@ fn settings_generation_context_ai_run_and_draft_round_trip() { .expect("generation context should be created"); assert_eq!(context.session_id, session.id); assert_eq!( - count_rows(service.database().connection(), "generation_context_entries"), + count_rows( + service.database().connection(), + "generation_context_entries" + ), 1 ); @@ -196,11 +203,13 @@ fn body_format_null_resets_to_default_html_for_rich_records() { .update_entry( &entry.id, EntryPatch { + body_json: Some(None), body_format: Some(None), ..EntryPatch::default() }, ) .expect("entry body format should reset"); + assert_eq!(entry.body_json, None); assert_eq!(entry.body_format.as_deref(), Some("html")); let finding = service @@ -218,16 +227,18 @@ fn body_format_null_resets_to_default_html_for_rich_records() { .update_finding( &finding.id, FindingPatch { + body_json: Some(None), body_format: Some(None), ..FindingPatch::default() }, ) .expect("finding body format should reset"); + assert_eq!(finding.body_json, None); assert_eq!(finding.body_format.as_deref(), Some("html")); let draft = service .create_draft(DraftCreate { - session_id: session.id, + session_id: session.id.clone(), ai_run_id: None, kind: DraftKind::SessionReport, title: "Draft".to_string(), @@ -241,14 +252,52 @@ fn body_format_null_resets_to_default_html_for_rich_records() { .update_draft( &draft.id, DraftPatch { + body_json: Some(None), body_format: Some(None), ..DraftPatch::default() }, ) .expect("draft body format should reset"); + assert_eq!(draft.body_json, None); assert_eq!(draft.body_format.as_deref(), Some("html")); -} + let entry = service + .update_entry( + &entry.id, + EntryPatch { + body_json: Some(Some(r#"{"type":"doc"}"#.to_string())), + body_format: Some(Some("tiptap_json".to_string())), + ..EntryPatch::default() + }, + ) + .expect("entry rich body should prepare generated update"); + let ai_run = service + .create_ai_run(AiRunCreate { + session_id: session.id, + generation_context_id: None, + provider: AiProvider::CodexCli, + model: "gpt-test".to_string(), + reasoning_effort: None, + prompt_version: "summary-v1".to_string(), + }) + .expect("Summary AI Run should create"); + let (_, generated_entry) = service + .complete_ai_run_with_generated_note_update( + &ai_run.id, + &entry.id, + &entry.body, + EntryPatch { + body: Some("

Generated Summary

".to_string()), + body_json: Some(None), + body_format: Some(None), + ..EntryPatch::default() + }, + ) + .expect("generated Summary update should reset rich body columns"); + assert_eq!(generated_entry.body, "

Generated Summary

"); + assert_eq!(generated_entry.body_json, None); + assert_eq!(generated_entry.body_format.as_deref(), Some("html")); +} #[test] fn deleting_draft_preserves_ai_run_and_other_drafts() { diff --git a/docs/ci.md b/docs/ci.md index 21a2d55..d695446 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -17,7 +17,10 @@ The CI workflow is deliberately divided by responsibility: `CI success` dependency graph while it accumulates promotion evidence. - **Package test (Linux)** builds and validates distributable Linux artifacts only when a packaging input changes. It starts only after the quality gate - succeeds. + succeeds. After metadata validation it installs and launches the final deb + and executes the AppImage on Ubuntu, then mounts the same artifact directory + into a digest-pinned Fedora 43 container where `dnf` resolves the final RPM's + dependencies before launch. Each smoke uses isolated XDG state under Xvfb. - **CI success** is the stable branch-protection check. It fails when any applicable job fails and accepts intentionally skipped platform/package jobs. @@ -34,11 +37,13 @@ local and CI lint behavior cannot drift. Release and packaging jobs install the exact Tauri CLI tool version from `scripts/tool-versions.json` through `scripts/install-tauri-cli.mjs`. The installer rejects a pin whose major/minor does not match the `tauri` runtime resolved in `Cargo.lock`; patch versions are -allowed to differ because the runtime and CLI are published independently. +allowed to differ because the runtime and CLI are published independently. The +same tool-version source pins `cargo-audit`, and the shared CI/release validation +action installs that exact version through `scripts/install-cargo-audit.mjs`. `.github/actions/validate-build/action.yml` is shared by CI and tag validation. `.github/actions/run-built-app-e2e/action.yml` owns built-app execution, -production restoration, summaries, and reliability artifacts for Linux and +independent production-frontend verification, summaries, and reliability artifacts for Linux and macOS so their safety and evidence behavior cannot drift. Any new repository-wide contract added to `bun run verify` should also be added to the validation action when it benefits from a separately named CI step. @@ -58,9 +63,10 @@ The Linux quality and release-validation jobs install Xvfb and run the shared built-app gate as a required control. The observational macOS arm64 job invokes the same shared action without Xvfb. `bun run e2e` uses an isolated temporary application-data directory and a deterministic local provider fixture; it does -not use accounts, network calls, or user data. Every gate restores the -production frontend and reruns `bun run e2e:isolation` after the test binary -completes. +not use accounts, network calls, or user data. The E2E frontend is built into +that temporary root and never replaces `frontend/dist`. Every gate reruns +an independent production frontend build and `bun run e2e:isolation` after the +test binary completes. Every Linux execution uploads a 90-day `qa-scribe-e2e-passed-*` or `qa-scribe-e2e-failed-*` marker containing its machine-readable metadata. macOS @@ -97,6 +103,19 @@ The Pages deployment job alone receives `pages: write` and `id-token: write`. Signing jobs receive only the secrets they use. All third-party actions are pinned to full commit SHAs, and Dependabot checks GitHub Actions updates weekly. +Release artifact smoke does not change those privilege boundaries. Linux +package installation uses the disposable runner's existing `sudo` access, and +the build job remains `contents: read`. RPM installation instead runs as root +inside a disposable, digest-pinned Fedora 43 container and uses `dnf` without +`--nodeps` before installing the Xvfb launch harness; the repository, final +artifacts, and host Bun executable are mounted read-only. The release job also installs the exact +generated APT setup deb, verifies the installed keyring and Deb822 source bytes, +mode, and ownership, then purges it. macOS mounts each signed/notarized DMG, +copies its app bundle to a temporary directory, and launches the copied +executable only after the temporary signing keychain, certificate, and API key +have been removed, and before the artifact is staged for publication. Signing, +notarization, checksum, and Gatekeeper checks still run independently. + ## Repository settings Code cannot enforce repository policy. Configure GitHub to: diff --git a/docs/code-size-guidelines.md b/docs/code-size-guidelines.md index cef1f5c..d7b2a79 100644 --- a/docs/code-size-guidelines.md +++ b/docs/code-size-guidelines.md @@ -4,7 +4,7 @@ qa-scribe uses file size as a maintainability signal, not as a mechanical rewrit ## Threshold -- Split maintained source and test files above 500 physical lines when there is a cohesive module seam. After Phase 4 of `docs/refactoring-roadmap.md`, exceeding this threshold requires an explicit exception. +- Split maintained source, test, and workflow files above 500 physical lines when there is a cohesive module seam. After Phase 4 of `docs/refactoring-roadmap.md`, exceeding this threshold requires an explicit exception. - Treat 300-500 physical lines as a watch range. Leave cohesive files alone until a real responsibility split appears. - Exclude generated files, binary assets, lockfiles, packaged icon outputs, and release metadata where splitting would reduce clarity. - Prefer behavior-preserving extractions with unchanged public exports, Tauri command names, storage schemas, and product language. @@ -60,7 +60,10 @@ At v0.7.10, no maintained app source or test file remained above 500 physical li - `scripts/validate_linux_package_metadata.py` - `.github/workflows/release.yml` -These remained watch-list items rather than automatic split targets because they are operational packaging/release code, not primary app source, and no low-risk cohesive split seam was identified. +The Python packaging commands remain reviewed operational exclusions. YAML +workflows are now scanned as maintained files; the cohesive release workflow +uses the same dated exception model because no low-risk responsibility seam has +yet been demonstrated. ## Current Audit @@ -79,7 +82,7 @@ This is documentation and test-organization drift, not a production-source regre Phase 4 adds a repository check with these semantics: -- fail on a maintained source or test file above 500 physical lines unless it has a current exception; +- fail on a maintained source, test, or YAML workflow file above 500 physical lines unless it has a current exception; - report, but do not fail, maintained files in the 300-500 watch range; - ignore generated files, binary assets, lockfiles, packaged icons, and release metadata; - include explicitly reviewed operational-script exclusions; diff --git a/docs/codebase-audit-2026-07-20.json b/docs/codebase-audit-2026-07-20.json new file mode 100644 index 0000000..76f6b2a --- /dev/null +++ b/docs/codebase-audit-2026-07-20.json @@ -0,0 +1,502 @@ +{ + "version": "audit-ledger/v1", + "findings": [ + { + "id": "AUD-001", + "title": "Recovered Summary completion can be overwritten by stale frontend state", + "summary": "If startup hydrates the pre-Summary Note before a recovered backend job commits its generated Note, reconciliation polls only job status and does not reload or version-check the Note. A later edit autosaves the stale visible body through an unconditional Entry update and can overwrite the generated Summary.", + "sourceLocators": [ + { "file": "frontend/src/app/generationActions.ts", "symbol": "reconcileActiveJobs / pollJobToTerminal", "line": 73, "endLine": 113 }, + { "file": "frontend/src/app/useAppStartup.ts", "symbol": "boot", "line": 68, "endLine": 85 }, + { "file": "frontend/src/app/sessionActions.ts", "symbol": "saveBody", "line": 242, "endLine": 265 }, + { "file": "src-tauri/src/commands/ai/job_runner.rs", "symbol": "run_ai_action_job", "line": 198, "endLine": 212 } + ], + "proofState": "source_proven", + "reachability": "failure_path", + "deploymentContext": { "exposure": "distributed", "description": "Packaged single-WebView desktop app when the WebView reloads while a Summary job remains active." }, + "trigger": "Start a Summary, reload the WebView, let startup hydrate the old Note before backend completion, then edit the still-visible old Note after the job reports completed.", + "guardsAndRecovery": { "effectiveness": "partial", "evidence": "Summary persistence checks the prepared database body and frontend write versions reject stale frontend responses, but generationActions.ts:94-113 only restores terminal status; sessionActions.ts:242-265 later saves the visible body without a backend revision precondition." }, + "disposition": "confirmed", + "impact": { "level": "major", "description": "The UI can misrepresent successful generation and a later user edit can replace the generated Summary with stale pre-generation content." }, + "severity": "high", + "actionPriority": "next", + "confidence": "high", + "falsifier": "A current reconciliation path that reloads or revision-checks the active Note before edits can autosave would falsify the finding.", + "remediation": "Reconcile recovered completion by action type, reload or revision-check the active Note, preserve dirty local edits, and make Note saves conflict-aware." + }, + { + "id": "AUD-002", + "title": "Session and output-library navigation lack latest-intent ordering", + "summary": "Session opens and cross-Session library loads have no shared request epoch or cancellation. Rapid navigation can apply an older open or list response after a newer user choice.", + "sourceLocators": [ + { "file": "frontend/src/app/useAppController.ts", "symbol": "openLibraryRecord", "line": 257, "endLine": 263 }, + { "file": "frontend/src/app/sessionActions.ts", "symbol": "openSession", "line": 89, "endLine": 121 }, + { "file": "frontend/src/app/useOutputLibraries.ts", "symbol": "loadDraftLibrary / loadFindingLibrary", "line": 21, "endLine": 55 } + ], + "proofState": "source_proven", + "reachability": "normal_path", + "deploymentContext": { "exposure": "distributed", "description": "Normal desktop navigation through Session and cross-Session library views." }, + "trigger": "Activate two records in different older Sessions before the first reopen resolves, or leave and re-enter a library while its earlier list request remains unresolved.", + "guardsAndRecovery": { "effectiveness": "partial", "evidence": "Most controls disable after busy state renders and record hydration has local versions, but reopen occurs before openSession owns busy state and neither openSession nor useOutputLibraries rejects stale completions." }, + "disposition": "confirmed", + "impact": { "level": "moderate", "description": "The app can show a different Session or stale library contents until another navigation or reload." }, + "severity": "medium", + "actionPriority": "next", + "confidence": "high", + "falsifier": "A single-flight or epoch-based coordinator covering reopen, Session open, library load, and operation-scoped busy release would falsify the finding.", + "remediation": "Introduce latest-intent request ordering for Session and library navigation without adding a global state framework." + }, + { + "id": "AUD-003", + "title": "Provider discovery requests can overwrite newer observations", + "summary": "Fast startup status, automatic Settings discovery, preflight refresh, and manual refresh write the same provider state without request identity, so an older completion can replace newer deep discovery.", + "sourceLocators": [ + { "file": "frontend/src/hooks/useSettingsController.ts", "symbol": "provider status loaders", "line": 82, "endLine": 114 }, + { "file": "frontend/src/app/useAppStartup.ts", "symbol": "provider startup refresh", "line": 31, "endLine": 39 }, + { "file": "frontend/src/app/useSettingsDiscovery.ts", "symbol": "useSettingsDiscovery", "line": 15, "endLine": 37 } + ], + "proofState": "source_proven", + "reachability": "normal_path", + "deploymentContext": { "exposure": "distributed", "description": "Desktop startup, Settings, and generation preflight all invoke provider observation paths." }, + "trigger": "Start deep or manual discovery while the post-boot fast request is unresolved, then resolve the newer request first and the older request last.", + "guardsAndRecovery": { "effectiveness": "partial", "evidence": "Automatic discovery suppresses some duplicate requests, but startup and deep paths do not share an epoch and setters do not compare request generation or depth." }, + "disposition": "confirmed", + "impact": { "level": "moderate", "description": "Fresh provider catalog/default evidence can regress to older or shallower state, misleading readiness and configuration UI." }, + "severity": "medium", + "actionPriority": "next", + "confidence": "high", + "falsifier": "A request coordinator that prevents fast or older results from replacing newer deep observations would falsify the finding.", + "remediation": "Centralize provider observation ownership and apply monotonic request/depth ordering while retaining independent catalog and default lifecycles." + }, + { + "id": "AUD-004", + "title": "Blank Session titles bypass dirty-state protection", + "summary": "The title input accepts blank text, but autosave, forced save, navigation, and close predicates all require a truthy trimmed title. A cleared title is silently discarded while the UI can still report Autosaved.", + "sourceLocators": [ + { "file": "frontend/src/app/useAppController.ts", "symbol": "title autosave effect", "line": 395, "endLine": 405 }, + { "file": "frontend/src/app/sessionActions.ts", "symbol": "hasPendingSessionEdits / saveNoteNow", "line": 76, "endLine": 87 }, + { "file": "frontend/src/app/sessionActions.ts", "symbol": "saveNoteNow", "line": 268, "endLine": 285 }, + { "file": "frontend/src/app/usePendingChangeProtection.ts", "symbol": "hasPendingChanges", "line": 42, "endLine": 48 } + ], + "proofState": "source_proven", + "reachability": "normal_path", + "deploymentContext": { "exposure": "distributed", "description": "Normal Session title editing in the packaged desktop app." }, + "trigger": "Clear the active Session title, wait past autosave, then navigate or close without changing another field.", + "guardsAndRecovery": { "effectiveness": "ineffective", "evidence": "Every title dirty/save predicate requires a nonempty trimmed title, so no backend validation, save error, or close guard runs for blank local state." }, + "disposition": "confirmed", + "impact": { "level": "moderate", "description": "The user receives false persistence feedback and loses the attempted edit, although the previous valid title remains stored." }, + "severity": "medium", + "actionPriority": "next", + "confidence": "high", + "falsifier": "A frontend invalid/dirty state that blocks or explicitly discards navigation and close for blank titles would falsify the finding.", + "remediation": "Treat every title difference as pending, validate required text explicitly, and never label an invalid title as autosaved." + }, + { + "id": "AUD-005", + "title": "Managed attachment previews are re-read on editor updates", + "summary": "Editor synchronization schedules hydration for every managed image, and hydration invokes the Tauri preview command even for already-resolved IDs. Typing in image-bearing records can repeatedly read and base64-transfer the same files.", + "sourceLocators": [ + { "file": "frontend/src/editor/RichTextEditor.tsx", "symbol": "value and update effects", "line": 236, "endLine": 265 }, + { "file": "frontend/src/editor/RichTextEditor.tsx", "symbol": "queueManagedPreviewHydration", "line": 376, "endLine": 383 }, + { "file": "frontend/src/editor/editorHtml.ts", "symbol": "hydrateManagedAttachmentPreviews", "line": 25, "endLine": 45 }, + { "file": "src-tauri/src/commands/files.rs", "symbol": "get_attachment_preview_data_url", "line": 59, "endLine": 68 } + ], + "proofState": "source_proven", + "reachability": "normal_path", + "deploymentContext": { "exposure": "distributed", "description": "Rich Note, Testware, and Finding editing in the desktop WebView." }, + "trigger": "Open a rich record with managed images and type repeatedly.", + "guardsAndRecovery": { "effectiveness": "partial", "evidence": "Same-turn work is coalesced and stale DOM application is rejected, but resolved and in-flight preview reads are not cached across transactions." }, + "disposition": "confirmed", + "impact": { "level": "moderate", "description": "Image-heavy records incur avoidable file reads, allocation, and large IPC responses on the typing path." }, + "severity": "medium", + "actionPriority": "next", + "confidence": "high", + "falsifier": "A resolved/in-flight cache or proof that hydration does not run on editor updates would falsify the finding.", + "remediation": "Cache previews by attachment identity, deduplicate in-flight reads, and hydrate only nodes whose identity or resolved source changed." + }, + { + "id": "AUD-006", + "title": "Generation cancellation is not authoritative across readiness and persistence", + "summary": "Per-job cancellation does not reach provider readiness, and after provider execution returns there is no cancellation check before final persistence. Relevant running and terminal JobStore transitions also do not enforce cancellation-state preconditions.", + "sourceLocators": [ + { "file": "src-tauri/src/jobs.rs", "symbol": "mark_running / complete / cancel", "line": 275, "endLine": 367 }, + { "file": "src-tauri/src/commands/ai/job_runner.rs", "symbol": "run_ai_action_job", "line": 96, "endLine": 212 }, + { "file": "src-tauri/src/commands/ai/provider_execution.rs", "symbol": "execute_provider_generation_streaming", "line": 26, "endLine": 50 } + ], + "proofState": "source_proven", + "reachability": "normal_path", + "deploymentContext": { "exposure": "distributed", "description": "User cancellation of an active AI action in the desktop app." }, + "trigger": "Cancel during deep readiness, or cancel after provider output is classified but before finish_ai_action_generation persists its result.", + "guardsAndRecovery": { "effectiveness": "partial", "evidence": "Streaming checks JobControl and kills registered process trees, but readiness uses separate cancellation, job_runner.rs:182-200 has no final control check, and jobs.rs terminal transitions can replace Cancelling." }, + "disposition": "confirmed", + "impact": { "level": "moderate", "description": "Cancellation can stall for bounded readiness time or still produce a persistent Draft, Finding, or Note and report Completed." }, + "severity": "medium", + "actionPriority": "next", + "confidence": "high", + "falsifier": "Cancellation-aware readiness, a final persistence guard, and state-conditional JobStore transitions would falsify the finding.", + "remediation": "Pass per-job cancellation through readiness, check before persistence, and enforce legal state transitions with deterministic race tests." + }, + { + "id": "AUD-007", + "title": "Neutral provider directory creation fails open", + "summary": "If creation of the fresh private provider directory fails, discovery and generation silently run in the shared process temporary directory despite the accepted neutral-discovery invariant.", + "sourceLocators": [ + { "file": "src-tauri/src/provider_command.rs", "symbol": "NeutralProviderCwd::new", "line": 43, "endLine": 67 }, + { "file": "src-tauri/src/commands/providers/probe.rs", "symbol": "SystemProbeRunner::run", "line": 105, "endLine": 130 }, + { "file": "src-tauri/src/commands/ai/streaming_exec.rs", "symbol": "ProcessProviderExecutor::execute", "line": 109, "endLine": 125 }, + { "file": "docs/adr/0010-session-workspace-and-cli-discovery.md", "line": 54, "endLine": 57 } + ], + "proofState": "source_proven", + "reachability": "failure_path", + "deploymentContext": { "exposure": "distributed", "description": "All local provider inspection and generation subprocesses use this working-directory abstraction." }, + "trigger": "Make UUID directory creation fail while the shared temporary root remains available, then run discovery or generation.", + "guardsAndRecovery": { "effectiveness": "ineffective", "evidence": "provider_command.rs:57-67 unconditionally returns env::temp_dir with owned=false and callers receive no error or degraded-scope signal." }, + "disposition": "confirmed", + "impact": { "level": "moderate", "description": "Provider behavior can inherit files or configuration from a shared non-owned directory, weakening privacy and reproducibility." }, + "severity": "medium", + "actionPriority": "next", + "confidence": "high", + "falsifier": "A fail-closed path or fallback that still guarantees a newly owned private directory would falsify the finding.", + "remediation": "Return an actionable error or create another guaranteed-private directory; never execute providers in the shared temp root." + }, + { + "id": "AUD-008", + "title": "Provider temp cleanup test races parallel tests", + "summary": "A cleanup test snapshots all same-process probe directories while other tests create directories with the same prefix outside its local mutex. The full Tauri suite failed once and the isolated test passed.", + "sourceLocators": [ + { "file": "src-tauri/src/commands/providers/tests/mod.rs", "symbol": "provider_probe_cleans_temp_files_when_spawn_fails", "line": 23, "endLine": 38 }, + { "file": "src-tauri/src/commands/providers/tests/mod.rs", "symbol": "provider_probe_temp_files", "line": 108, "endLine": 126 }, + { "file": "src-tauri/src/commands/providers/probe/command.rs", "symbol": "ProbeOutputFiles::new", "line": 92, "endLine": 109 }, + { "file": "src-tauri/src/commands/providers/probe/command.rs", "symbol": "probe_output_directory_and_files_are_private", "line": 206, "endLine": 235 } + ], + "proofState": "reproduced", + "reachability": "normal_path", + "deploymentContext": { "exposure": "not_deployed", "description": "Rust validation and CI only; production cleanup behavior was not implicated." }, + "trigger": "Run qa-scribe-tauri tests in parallel with the global-snapshot cleanup test overlapping another same-prefix ProbeOutputFiles test.", + "guardsAndRecovery": { "effectiveness": "ineffective", "evidence": "The mutex in providers/tests/mod.rs is not shared by probe/command.rs tests; isolated rerun passing after full-suite failure confirms an inter-test observation race." }, + "disposition": "confirmed", + "impact": { "level": "moderate", "description": "Required CI or release validation can fail nondeterministically and normalize reruns." }, + "severity": "medium", + "actionPriority": "next", + "confidence": "high", + "falsifier": "Path-specific assertions or one shared lock across every same-prefix test would falsify the finding.", + "remediation": "Assert cleanup for the exact temporary path created by the test, avoiding process-global before/after snapshots." + }, + { + "id": "AUD-009", + "title": "Structured protocol output can fall back to raw JSON", + "summary": "A zero-exit structured provider run with no recognized assistant event falls back to complete raw stdout. Nonempty protocol JSON survives content validation and can be persisted, including Summary when the selected Note has not changed.", + "sourceLocators": [ + { "file": "crates/qa-scribe-core/src/ai/stream/mod.rs", "symbol": "push_json_line", "line": 73, "endLine": 90 }, + { "file": "crates/qa-scribe-core/src/ai/executor.rs", "symbol": "ProviderGenerationOutput::response_text", "line": 106, "endLine": 117 }, + { "file": "crates/qa-scribe-core/src/generation/workflow.rs", "symbol": "finish_successful_generation", "line": 223, "endLine": 264 }, + { "file": "src-tauri/src/commands/ai/streaming_exec.rs", "symbol": "StreamingProcessExecutor::execute", "line": 178, "endLine": 225 } + ], + "proofState": "source_proven", + "reachability": "failure_path", + "deploymentContext": { "exposure": "distributed", "description": "Local structured Claude/Codex provider execution; current documented formats are recognized, so the trigger is malformed or future/unsupported protocol drift." }, + "trigger": "A structured provider exits zero after emitting only malformed or unsupported nonempty event records.", + "guardsAndRecovery": { "effectiveness": "partial", "evidence": "Line and total output are bounded, malformed events produce progress, and Summary checks the prepared Note body; executor.rs:111-117 still falls back to raw stdout and workflow validation rejects only sanitized-empty content." }, + "disposition": "confirmed", + "impact": { "level": "moderate", "description": "The app can create apparently successful records containing protocol envelopes; an unchanged Note can be replaced by that content during Summary." }, + "severity": "medium", + "actionPriority": "next", + "confidence": "high", + "falsifier": "Rejecting successful structured runs with no parsed assistant text, while retaining raw fallback only for plain text, would falsify the finding.", + "remediation": "Make output format explicit in response_text and fail structured runs without recognized assistant content using an actionable compatibility error." + }, + { + "id": "AUD-010", + "title": "Preserve evidence off still restores source images", + "summary": "The user-visible preserveEvidence preference reaches prompt text and metadata, but Testware completion restores omitted managed and external source images unconditionally.", + "sourceLocators": [ + { "file": "crates/qa-scribe-core/src/generation/preferences.rs", "symbol": "TestwareGenerationPreferences", "line": 41, "endLine": 52 }, + { "file": "crates/qa-scribe-core/src/generation/preferences.rs", "symbol": "testware_preferences_prompt", "line": 197, "endLine": 235 }, + { "file": "crates/qa-scribe-core/src/generation/workflow.rs", "symbol": "finish_testware_generation", "line": 287, "endLine": 319 }, + { "file": "frontend/src/workflows/generationPreflight.tsx", "line": 183, "endLine": 190 } + ], + "proofState": "source_proven", + "reachability": "normal_path", + "deploymentContext": { "exposure": "distributed", "description": "The option is exposed in the desktop generation preflight and consumed by core generation." }, + "trigger": "Uncheck Preserve evidence, generate Testware from a Note with an image, and have the provider omit that image.", + "guardsAndRecovery": { "effectiveness": "ineffective", "evidence": "workflow.rs:293-297 calls preserve_managed_attachment_images regardless of the preference value." }, + "disposition": "confirmed", + "impact": { "level": "moderate", "description": "Generated Testware contradicts an explicit user choice and can retain screenshots or external Evidence intended for omission." }, + "severity": "medium", + "actionPriority": "next", + "confidence": "high", + "falsifier": "A documented advisory-only contract or conditional restoration keyed to preserveEvidence would falsify the finding.", + "remediation": "Define the preference contract explicitly and skip deterministic restoration when false while still sanitizing provider-returned images." + }, + { + "id": "AUD-011", + "title": "Rich-record patch logic is duplicated across persistence paths", + "summary": "Rich body merge, validation, SQL update, and readback mechanics are repeated across Entry, conditional Entry, generated Summary, Draft, and Finding paths. Transaction boundaries are intentionally distinct, but patch resolution and column mapping must change together.", + "sourceLocators": [ + { "file": "crates/qa-scribe-core/src/services/session_service/entries.rs", "symbol": "update_entry / update_entry_if_body_matches", "line": 83, "endLine": 178 }, + { "file": "crates/qa-scribe-core/src/services/session_service/generation.rs", "symbol": "complete_ai_run_with_generated_note_update", "line": 220, "endLine": 285 }, + { "file": "crates/qa-scribe-core/src/services/session_service/drafts.rs", "symbol": "update_draft", "line": 82, "endLine": 122 }, + { "file": "crates/qa-scribe-core/src/services/session_service/findings.rs", "symbol": "update_finding", "line": 76, "endLine": 117 } + ], + "proofState": "source_proven", + "reachability": "normal_path", + "deploymentContext": { "exposure": "deployed", "description": "Normal record editing and AI Summary persistence in the local Rust core." }, + "trigger": "Change rich-body patch semantics, validation, or format defaults and update only some repeated paths.", + "guardsAndRecovery": { "effectiveness": "partial", "evidence": "Shared validators, row mappers, and transactions limit drift, but no shared typed patch resolver exists and the conditional Entry update has no production caller." }, + "disposition": "hardening", + "impact": { "level": "minor", "description": "No current mismatch was proven, but future storage/editor changes carry synchronized-change and regression risk." }, + "severity": "low", + "actionPriority": "backlog", + "confidence": "high", + "falsifier": "One shared patch resolver used by each transaction wrapper, plus removal or coverage of the unused conditional API, would falsify the smell.", + "remediation": "Extract only shared rich-body resolution and column mapping; retain distinct transaction orchestration and stale-write guards." + }, + { + "id": "AUD-012", + "title": "HTML sanitizer is not quote-aware when locating tag ends", + "summary": "The Rust sanitizer and projection scanner use the first raw greater-than character as a tag terminator. A valid greater-than inside a quoted attribute can split and corrupt the tag, though allowlisting still blocks a demonstrated script-execution path.", + "sourceLocators": [ + { "file": "crates/qa-scribe-core/src/generation/response.rs", "symbol": "sanitize_editor_html_fragment", "line": 170, "endLine": 209 }, + { "file": "crates/qa-scribe-core/src/generation/response.rs", "symbol": "sanitize_opening_editor_tag", "line": 265, "endLine": 350 }, + { "file": "crates/qa-scribe-core/src/generation/html_projection.rs", "symbol": "HtmlPromptProjector::project", "line": 25, "endLine": 63 } + ], + "proofState": "source_proven", + "reachability": "normal_path", + "deploymentContext": { "exposure": "deployed", "description": "All generated Draft, Finding, and Summary HTML passes through these scanners." }, + "trigger": "Return valid allowed-tag HTML with an unescaped greater-than inside a quoted attribute, such as img alt=\"A > B\".", + "guardsAndRecovery": { "effectiveness": "partial", "evidence": "Allowed tags and safe URL schemes are rebuilt and active attributes are dropped, but outer scanners use find('>') without quote state." }, + "disposition": "hardening", + "impact": { "level": "minor", "description": "Generated content and attributes can be silently corrupted; no security bypass was demonstrated." }, + "severity": "low", + "actionPriority": "backlog", + "confidence": "high", + "falsifier": "A quote-aware tokenizer/parser and regression tests for quoted delimiters would falsify the finding.", + "remediation": "Replace first-delimiter scanning with a quote-aware tokenizer or maintained HTML fragment parser, preserving the current allowlist." + }, + { + "id": "AUD-013", + "title": "Final release packages are not installed or executed", + "summary": "The final deb, rpm, AppImage, and DMG receive varying metadata, signature, or existence checks but are not installed or executed. The generated APT setup deb is also not installed and its installed keyring/source files are not verified.", + "sourceLocators": [ + { "file": ".github/workflows/ci.yml", "line": 209, "endLine": 235 }, + { "file": ".github/workflows/release.yml", "line": 301, "endLine": 346 }, + { "file": ".github/workflows/release.yml", "line": 478, "endLine": 499 }, + { "file": "scripts/validate_linux_package_metadata.py", "symbol": "validate_package", "line": 317, "endLine": 427 }, + { "file": "scripts/check-apt-installer.mjs", "symbol": "validateAptInstallStaging", "line": 143, "endLine": 268 } + ], + "proofState": "invariant_only", + "reachability": "failure_path", + "deploymentContext": { "exposure": "distributed", "description": "These are the package formats and repository bootstrap delivered to users." }, + "trigger": "Introduce a package-only dependency, loader, permission, payload, or setup-file defect that preserves currently checked metadata and signatures.", + "guardsAndRecovery": { "effectiveness": "partial", "evidence": "Source-build E2E, metadata extraction, codesign/notarization, Gatekeeper, checksums, and mocked installer flow provide substantial coverage, but no final-format install/execute check exists." }, + "disposition": "hardening", + "impact": { "level": "major", "description": "A release can distribute an installer that cannot install or launch, requiring asset replacement or a corrective release." }, + "severity": "medium", + "actionPriority": "next", + "confidence": "high", + "falsifier": "Format-appropriate disposable install and smoke checks for each required package, including setup-file verification, would falsify the gap.", + "remediation": "Add post-build package smoke jobs: install deb/rpm in disposable environments, execute AppImage, mount/copy/launch the DMG app, and install/verify the actual setup deb." + }, + { + "id": "AUD-014", + "title": "Required E2E cases share state and cancellation uses fixed timing", + "summary": "All four critical E2E cases share one app-data directory and application instance. The cancellation case races a provider fixture that begins output after 350 ms and completes after 1050 ms rather than waiting for a deterministic test signal.", + "sourceLocators": [ + { "file": "scripts/run-e2e.mjs", "line": 9, "endLine": 46 }, + { "file": "e2e/wdio.conf.mjs", "line": 9, "endLine": 23 }, + { "file": "e2e/specs/critical-workflows.e2e.mjs", "line": 65, "endLine": 153 }, + { "file": "e2e/fixtures/bin/codex", "line": 22, "endLine": 49 }, + { "file": "docs/quality-scenarios.md", "line": 19, "endLine": 29 } + ], + "proofState": "source_proven", + "reachability": "normal_path", + "deploymentContext": { "exposure": "not_deployed", "description": "Required Linux CI/release gate and observational macOS gate." }, + "trigger": "Run on a loaded runner or let an earlier case fail with persistent state before the cancellation case executes.", + "guardsAndRecovery": { "effectiveness": "partial", "evidence": "The suite is isolated from user data, uses one instance, and has explicit UI waits, but it lacks per-case reset and the fixture has fixed timers with no hold/release handshake." }, + "disposition": "confirmed", + "impact": { "level": "moderate", "description": "The gate can flake or become order-dependent, weakening confidence and encouraging reruns." }, + "severity": "medium", + "actionPriority": "next", + "confidence": "high", + "falsifier": "Fresh app state per case plus a cancellation fixture that blocks until explicit release or process termination would falsify the finding.", + "remediation": "Split or reset cases into isolated app sessions and make the cancellation fixture deterministic rather than time-raced." + }, + { + "id": "AUD-015", + "title": "Code-size policy omits maintained YAML workflows", + "summary": "The enforced maintained-file extensions omit yml and yaml, so the 785-line release workflow is invisible to threshold, watch-range, and dated exception checks despite documentation naming it as an operational watch item.", + "sourceLocators": [ + { "file": "scripts/check-code-size.mjs", "line": 7, "endLine": 35 }, + { "file": "scripts/code-size-policy.json", "line": 5, "endLine": 20 }, + { "file": ".github/workflows/release.yml", "line": 1, "endLine": 785 }, + { "file": "docs/code-size-guidelines.md", "line": 55, "endLine": 63 } + ], + "proofState": "source_proven", + "reachability": "normal_path", + "deploymentContext": { "exposure": "not_deployed", "description": "Repository maintainability policy executed in CI and release validation." }, + "trigger": "Grow or modify a YAML workflow beyond policy thresholds and run code-size:check.", + "guardsAndRecovery": { "effectiveness": "partial", "evidence": "Python and other source extensions are scanned with dated exceptions, but SOURCE_EXTENSIONS excludes YAML entirely." }, + "disposition": "confirmed", + "impact": { "level": "minor", "description": "A major release-control surface can accumulate mixed responsibilities while the automated policy reports success." }, + "severity": "low", + "actionPriority": "backlog", + "confidence": "high", + "falsifier": "Scanning YAML with fixture coverage and a current split or reviewed exclusion for release.yml would falsify the finding.", + "remediation": "Add yml/yaml to maintained-file scanning and apply the existing dated exception model to the release workflow." + }, + { + "id": "AUD-016", + "title": "Version bump writes can leave a partial repository state", + "summary": "The version bump builds a complete plan in memory but overwrites seven files sequentially. A later write failure leaves earlier files updated, and the next preflight refuses to continue because versions disagree.", + "sourceLocators": [ + { "file": "scripts/bump-version.mjs", "symbol": "main", "line": 61, "endLine": 92 }, + { "file": "scripts/bump-version.mjs", "symbol": "preflightConsistencyCheck", "line": 121, "endLine": 149 }, + { "file": "scripts/bump-version.test.mjs", "line": 189, "endLine": 212 } + ], + "proofState": "source_proven", + "reachability": "failure_path", + "deploymentContext": { "exposure": "not_deployed", "description": "Maintainer release-preparation command whose outputs feed package and release metadata." }, + "trigger": "Make a later target unwritable or exhaust storage after one or more earlier writeFile calls succeed.", + "guardsAndRecovery": { "effectiveness": "partial", "evidence": "All files are read and transformed before writes and preflight catches prior drift, but the write loop has no staging, rollback, or resumable recovery." }, + "disposition": "confirmed", + "impact": { "level": "moderate", "description": "Release metadata can be left inconsistent and require manual source-control recovery before the tool can run again." }, + "severity": "medium", + "actionPriority": "next", + "confidence": "high", + "falsifier": "A tested staging/rollback or explicit resumable transaction that restores consistency on write failure would falsify the finding.", + "remediation": "Stage all outputs, replace atomically where possible, and roll back already-replaced files on failure; inject write failures in tests." + }, + { + "id": "AUD-017", + "title": "Authoritative cargo-audit tooling is unpinned", + "summary": "The shared CI/release action installs the latest available cargo-audit on every run, while other release-critical tools are pinned. Unchanged source can therefore gain new tool behavior independently of advisory database updates.", + "sourceLocators": [ + { "file": ".github/actions/validate-build/action.yml", "line": 89, "endLine": 99 }, + { "file": "scripts/tool-versions.json", "line": 1, "endLine": 3 }, + { "file": "scripts/check-rust-audit.mjs", "symbol": "run", "line": 91, "endLine": 116 }, + { "file": "docs/ci.md", "line": 28, "endLine": 44 } + ], + "proofState": "source_proven", + "reachability": "normal_path", + "deploymentContext": { "exposure": "not_deployed", "description": "Authoritative CI and tag validation environment." }, + "trigger": "A new cargo-audit version is published and a subsequent validation run installs it.", + "guardsAndRecovery": { "effectiveness": "partial", "evidence": "The audit runs locked and reconciles advisories strictly, but neither --locked nor the exception registry constrains the cargo-audit executable version." }, + "disposition": "hardening", + "impact": { "level": "moderate", "description": "The required gate can change or break without a repository change, reducing validation reproducibility." }, + "severity": "medium", + "actionPriority": "next", + "confidence": "high", + "falsifier": "Pinning and deliberately updating cargo-audit through reviewed tooling would falsify the finding.", + "remediation": "Add cargo-audit to the reviewed tool-version source and install that exact version in shared validation." + }, + { + "id": "AUD-R01", + "title": "Current schema initialization and migrations are retry-safe for deployed paths", + "summary": "The audit did not find an application path that stamps the current schema version before all feature-detecting helpers and foreign-key checks succeed. Current-version drift requires out-of-band local mutation, while interrupted older migrations retain the old version and retry.", + "sourceLocators": [ + { "file": "crates/qa-scribe-core/src/storage/mod.rs", "symbol": "initialize / migrate", "line": 72, "endLine": 144 }, + { "file": "crates/qa-scribe-core/src/storage/mod.rs", "symbol": "migration helpers", "line": 252, "endLine": 326 }, + { "file": "crates/qa-scribe-core/tests/session_storage/migrations/integrity.rs", "line": 1, "endLine": 37 } + ], + "proofState": "source_proven", + "reachability": "failure_path", + "deploymentContext": { "exposure": "deployed", "description": "SQLite initialization on desktop startup." }, + "trigger": "Interrupt an older migration or externally corrupt a database while retaining its current user_version.", + "guardsAndRecovery": { "effectiveness": "effective", "evidence": "Destructive helpers use immediate transactions, helpers detect existing features, foreign keys are checked, and user_version is written last; newer versions are rejected." }, + "disposition": "refuted", + "impact": { "level": "none", "description": "No reachable app-controlled incomplete-current schema or non-retryable migration was demonstrated." }, + "severity": "informational", + "actionPriority": "none", + "confidence": "high", + "falsifier": "A migration step that is neither transactional nor idempotent, or a path that stamps current before checks complete, would reopen the candidate." + }, + { + "id": "AUD-R02", + "title": "useStableCapability has no demonstrated reachable stale-context failure", + "summary": "The Proxy-backed capability pattern is unusual, but critical mutable state uses refs and write versions, and no concrete UI or native event was shown to invoke a stale context after commit but before its passive effect update.", + "sourceLocators": [ + { "file": "frontend/src/app/useStableCapability.ts", "symbol": "useStableCapability", "line": 8, "endLine": 26 }, + { "file": "frontend/src/app/useSessionWorkspace.ts", "symbol": "workspace refs", "line": 18, "endLine": 34 }, + { "file": "frontend/src/app/useAppController.lifecycle.test.ts", "line": 12, "endLine": 76 } + ], + "proofState": "unverified", + "reachability": "unknown", + "deploymentContext": { "exposure": "distributed", "description": "Shared frontend action-factory mechanism." }, + "trigger": "Hypothetical action invocation after a render commits but before the context ref passive effect runs.", + "guardsAndRecovery": { "effectiveness": "partial", "evidence": "Proxy reads occur at invocation time, critical state has refs and write versions, and lifecycle tests cover several stale async paths; no concrete trigger was established." }, + "disposition": "refuted", + "impact": { "level": "none", "description": "No evidenced product impact; unusual implementation alone is not an actionable smell." }, + "severity": "informational", + "actionPriority": "none", + "confidence": "medium", + "falsifier": "A reproducible handler or native event reading previous context in the post-commit window would reopen the candidate." + }, + { + "id": "AUD-R03", + "title": "Tauri command and capability surfaces are aligned for the current trust model", + "summary": "Build registration, Specta handler and bindings, default permission, generated permissions, and the main-window capability agree. The single bundled-code WebView model does not justify splitting the current command capability without a new principal boundary.", + "sourceLocators": [ + { "file": "src-tauri/build.rs", "symbol": "COMMANDS", "line": 8, "endLine": 47 }, + { "file": "src-tauri/src/specta_bindings.rs", "symbol": "builder", "line": 39, "endLine": 85 }, + { "file": "frontend/src/bindings.ts", "symbol": "commands", "line": 5, "endLine": 48 }, + { "file": "src-tauri/permissions/default.toml", "line": 3, "endLine": 39 }, + { "file": "src-tauri/capabilities/default.json", "line": 1, "endLine": 7 }, + { "file": "docs/tauri-threat-model.md", "line": 5, "endLine": 23 } + ], + "proofState": "reproduced", + "reachability": "normal_path", + "deploymentContext": { "exposure": "distributed", "description": "Production main WebView capability in the packaged desktop app." }, + "trigger": "Run the repository command-surface checker and assess authorization against the current single-principal threat model.", + "guardsAndRecovery": { "effectiveness": "effective", "evidence": "The checker reported 33 aligned commands; the capability targets main, withGlobalTauri is false, and no arbitrary WebView path or executable command was found." }, + "disposition": "refuted", + "impact": { "level": "none", "description": "No command drift or missing current principal boundary was found." }, + "severity": "informational", + "actionPriority": "none", + "confidence": "high", + "falsifier": "A mismatched command representation, remote/multi-principal content, broader production permission, or arbitrary path/process command would reopen the candidate." + }, + { + "id": "AUD-R04", + "title": "E2E promotion markers enforce platform-specific first-attempt history", + "summary": "The promotion checker separates Linux and macOS names, derives markers from the actual E2E step outcome, deduplicates run IDs, rejects failures and attempts other than one, and paginates through unrelated artifacts.", + "sourceLocators": [ + { "file": ".github/actions/run-built-app-e2e/action.yml", "line": 29, "endLine": 86 }, + { "file": "scripts/check-e2e-reliability.mjs", "symbol": "assessReliability / listArtifacts", "line": 21, "endLine": 74 }, + { "file": "scripts/check-e2e-reliability.test.mjs", "line": 15, "endLine": 92 } + ], + "proofState": "source_proven", + "reachability": "normal_path", + "deploymentContext": { "exposure": "not_deployed", "description": "CI evidence used for platform promotion decisions." }, + "trigger": "Audit artifact history containing unrelated items, mixed platforms, failures, and reruns.", + "guardsAndRecovery": { "effectiveness": "effective", "evidence": "Outcome-derived names, platform prefixes, pagination, newest-by-run deduplication, and runAttempt checks implement the documented metric." }, + "disposition": "refuted", + "impact": { "level": "none", "description": "The proposed marker-integrity bypass was not present." }, + "severity": "informational", + "actionPriority": "none", + "confidence": "high", + "falsifier": "A path that uploads matching passed names after failed E2E or counts duplicate runs/attempts would reopen the candidate." + }, + { + "id": "AUD-R05", + "title": "Failed APT staging cannot partially deploy to Pages", + "summary": "Although APT output is built incrementally, it is confined to a fresh runner staging directory. Artifact upload and Pages deployment require preceding signing, setup, validation, release-asset publication, and monotonic checks to succeed.", + "sourceLocators": [ + { "file": "scripts/build_apt_repository.py", "symbol": "build_repository", "line": 473, "endLine": 523 }, + { "file": ".github/workflows/release.yml", "line": 429, "endLine": 570 }, + { "file": ".github/workflows/release.yml", "line": 735, "endLine": 770 }, + { "file": "scripts/check-apt-monotonic.mjs", "symbol": "checkMonotonic", "line": 80, "endLine": 106 } + ], + "proofState": "source_proven", + "reachability": "failure_path", + "deploymentContext": { "exposure": "not_deployed", "description": "Release runner staging before live Pages deployment." }, + "trigger": "Fail signing, setup-package construction, installer validation, or artifact upload after staging writes begin.", + "guardsAndRecovery": { "effectiveness": "effective", "evidence": "set -e staging, downstream job dependencies, and monotonic checks prevent the partial runner tree from becoming the live repository." }, + "disposition": "refuted", + "impact": { "level": "none", "description": "A staging failure leaves ephemeral files only and cannot update Pages." }, + "severity": "informational", + "actionPriority": "none", + "confidence": "high", + "falsifier": "In-place live writes or deploy behavior after failed staging would reopen the candidate." + } + ] +} diff --git a/docs/codebase-audit-2026-07-20.md b/docs/codebase-audit-2026-07-20.md new file mode 100644 index 0000000..7b2f4b4 --- /dev/null +++ b/docs/codebase-audit-2026-07-20.md @@ -0,0 +1,543 @@ +# Audit ledger + + + +## Summary + +- Total findings: 22 +- Actionable findings: 17 +- Remediation items: 17 +- Dispositions: confirmed=13, hardening=4, measure_first=0, deferred=0, refuted=5 +- Severities: critical=0, high=1, medium=13, low=3, informational=5 +- Action priorities: fix_now=0, next=14, backlog=3, none=5 +- Proof states: reproduced=2, source_proven=18, invariant_only=1, external_assumption=0, unverified=1 + +## Findings + +### AUD-001 — Recovered Summary completion can be overwritten by stale frontend state + +If startup hydrates the pre-Summary Note before a recovered backend job commits its generated Note, reconciliation polls only job status and does not reload or version-check the Note\. A later edit autosaves the stale visible body through an unconditional Entry update and can overwrite the generated Summary\. + +- Proof state: `source_proven` +- Reachability: `failure_path` +- Deployment: `distributed` — Packaged single-WebView desktop app when the WebView reloads while a Summary job remains active\. +- Trigger: Start a Summary, reload the WebView, let startup hydrate the old Note before backend completion, then edit the still-visible old Note after the job reports completed\. +- Guards and recovery: `partial` — Summary persistence checks the prepared database body and frontend write versions reject stale frontend responses, but generationActions\.ts:94-113 only restores terminal status; sessionActions\.ts:242-265 later saves the visible body without a backend revision precondition\. +- Disposition: `confirmed` +- Impact: `major` — The UI can misrepresent successful generation and a later user edit can replace the generated Summary with stale pre-generation content\. +- Severity: `high` +- Action priority: `next` +- Confidence: `high` +- Falsifier: A current reconciliation path that reloads or revision-checks the active Note before edits can autosave would falsify the finding\. +- Sources: + - `frontend/src/app/generationActions.ts:73-113 — reconcileActiveJobs / pollJobToTerminal` + - `frontend/src/app/sessionActions.ts:242-265 — saveBody` + - `frontend/src/app/useAppStartup.ts:68-85 — boot` + - `src-tauri/src/commands/ai/job_runner.rs:198-212 — run_ai_action_job` + +### AUD-002 — Session and output-library navigation lack latest-intent ordering + +Session opens and cross-Session library loads have no shared request epoch or cancellation\. Rapid navigation can apply an older open or list response after a newer user choice\. + +- Proof state: `source_proven` +- Reachability: `normal_path` +- Deployment: `distributed` — Normal desktop navigation through Session and cross-Session library views\. +- Trigger: Activate two records in different older Sessions before the first reopen resolves, or leave and re-enter a library while its earlier list request remains unresolved\. +- Guards and recovery: `partial` — Most controls disable after busy state renders and record hydration has local versions, but reopen occurs before openSession owns busy state and neither openSession nor useOutputLibraries rejects stale completions\. +- Disposition: `confirmed` +- Impact: `moderate` — The app can show a different Session or stale library contents until another navigation or reload\. +- Severity: `medium` +- Action priority: `next` +- Confidence: `high` +- Falsifier: A single-flight or epoch-based coordinator covering reopen, Session open, library load, and operation-scoped busy release would falsify the finding\. +- Sources: + - `frontend/src/app/sessionActions.ts:89-121 — openSession` + - `frontend/src/app/useAppController.ts:257-263 — openLibraryRecord` + - `frontend/src/app/useOutputLibraries.ts:21-55 — loadDraftLibrary / loadFindingLibrary` + +### AUD-003 — Provider discovery requests can overwrite newer observations + +Fast startup status, automatic Settings discovery, preflight refresh, and manual refresh write the same provider state without request identity, so an older completion can replace newer deep discovery\. + +- Proof state: `source_proven` +- Reachability: `normal_path` +- Deployment: `distributed` — Desktop startup, Settings, and generation preflight all invoke provider observation paths\. +- Trigger: Start deep or manual discovery while the post-boot fast request is unresolved, then resolve the newer request first and the older request last\. +- Guards and recovery: `partial` — Automatic discovery suppresses some duplicate requests, but startup and deep paths do not share an epoch and setters do not compare request generation or depth\. +- Disposition: `confirmed` +- Impact: `moderate` — Fresh provider catalog/default evidence can regress to older or shallower state, misleading readiness and configuration UI\. +- Severity: `medium` +- Action priority: `next` +- Confidence: `high` +- Falsifier: A request coordinator that prevents fast or older results from replacing newer deep observations would falsify the finding\. +- Sources: + - `frontend/src/app/useAppStartup.ts:31-39 — provider startup refresh` + - `frontend/src/app/useSettingsDiscovery.ts:15-37 — useSettingsDiscovery` + - `frontend/src/hooks/useSettingsController.ts:82-114 — provider status loaders` + +### AUD-004 — Blank Session titles bypass dirty-state protection + +The title input accepts blank text, but autosave, forced save, navigation, and close predicates all require a truthy trimmed title\. A cleared title is silently discarded while the UI can still report Autosaved\. + +- Proof state: `source_proven` +- Reachability: `normal_path` +- Deployment: `distributed` — Normal Session title editing in the packaged desktop app\. +- Trigger: Clear the active Session title, wait past autosave, then navigate or close without changing another field\. +- Guards and recovery: `ineffective` — Every title dirty/save predicate requires a nonempty trimmed title, so no backend validation, save error, or close guard runs for blank local state\. +- Disposition: `confirmed` +- Impact: `moderate` — The user receives false persistence feedback and loses the attempted edit, although the previous valid title remains stored\. +- Severity: `medium` +- Action priority: `next` +- Confidence: `high` +- Falsifier: A frontend invalid/dirty state that blocks or explicitly discards navigation and close for blank titles would falsify the finding\. +- Sources: + - `frontend/src/app/sessionActions.ts:76-87 — hasPendingSessionEdits / saveNoteNow` + - `frontend/src/app/sessionActions.ts:268-285 — saveNoteNow` + - `frontend/src/app/useAppController.ts:395-405 — title autosave effect` + - `frontend/src/app/usePendingChangeProtection.ts:42-48 — hasPendingChanges` + +### AUD-005 — Managed attachment previews are re-read on editor updates + +Editor synchronization schedules hydration for every managed image, and hydration invokes the Tauri preview command even for already-resolved IDs\. Typing in image-bearing records can repeatedly read and base64-transfer the same files\. + +- Proof state: `source_proven` +- Reachability: `normal_path` +- Deployment: `distributed` — Rich Note, Testware, and Finding editing in the desktop WebView\. +- Trigger: Open a rich record with managed images and type repeatedly\. +- Guards and recovery: `partial` — Same-turn work is coalesced and stale DOM application is rejected, but resolved and in-flight preview reads are not cached across transactions\. +- Disposition: `confirmed` +- Impact: `moderate` — Image-heavy records incur avoidable file reads, allocation, and large IPC responses on the typing path\. +- Severity: `medium` +- Action priority: `next` +- Confidence: `high` +- Falsifier: A resolved/in-flight cache or proof that hydration does not run on editor updates would falsify the finding\. +- Sources: + - `frontend/src/editor/editorHtml.ts:25-45 — hydrateManagedAttachmentPreviews` + - `frontend/src/editor/RichTextEditor.tsx:236-265 — value and update effects` + - `frontend/src/editor/RichTextEditor.tsx:376-383 — queueManagedPreviewHydration` + - `src-tauri/src/commands/files.rs:59-68 — get_attachment_preview_data_url` + +### AUD-006 — Generation cancellation is not authoritative across readiness and persistence + +Per-job cancellation does not reach provider readiness, and after provider execution returns there is no cancellation check before final persistence\. Relevant running and terminal JobStore transitions also do not enforce cancellation-state preconditions\. + +- Proof state: `source_proven` +- Reachability: `normal_path` +- Deployment: `distributed` — User cancellation of an active AI action in the desktop app\. +- Trigger: Cancel during deep readiness, or cancel after provider output is classified but before finish\_ai\_action\_generation persists its result\. +- Guards and recovery: `partial` — Streaming checks JobControl and kills registered process trees, but readiness uses separate cancellation, job\_runner\.rs:182-200 has no final control check, and jobs\.rs terminal transitions can replace Cancelling\. +- Disposition: `confirmed` +- Impact: `moderate` — Cancellation can stall for bounded readiness time or still produce a persistent Draft, Finding, or Note and report Completed\. +- Severity: `medium` +- Action priority: `next` +- Confidence: `high` +- Falsifier: Cancellation-aware readiness, a final persistence guard, and state-conditional JobStore transitions would falsify the finding\. +- Sources: + - `src-tauri/src/commands/ai/job_runner.rs:96-212 — run_ai_action_job` + - `src-tauri/src/commands/ai/provider_execution.rs:26-50 — execute_provider_generation_streaming` + - `src-tauri/src/jobs.rs:275-367 — mark_running / complete / cancel` + +### AUD-007 — Neutral provider directory creation fails open + +If creation of the fresh private provider directory fails, discovery and generation silently run in the shared process temporary directory despite the accepted neutral-discovery invariant\. + +- Proof state: `source_proven` +- Reachability: `failure_path` +- Deployment: `distributed` — All local provider inspection and generation subprocesses use this working-directory abstraction\. +- Trigger: Make UUID directory creation fail while the shared temporary root remains available, then run discovery or generation\. +- Guards and recovery: `ineffective` — provider\_command\.rs:57-67 unconditionally returns env::temp\_dir with owned=false and callers receive no error or degraded-scope signal\. +- Disposition: `confirmed` +- Impact: `moderate` — Provider behavior can inherit files or configuration from a shared non-owned directory, weakening privacy and reproducibility\. +- Severity: `medium` +- Action priority: `next` +- Confidence: `high` +- Falsifier: A fail-closed path or fallback that still guarantees a newly owned private directory would falsify the finding\. +- Sources: + - `docs/adr/0010-session-workspace-and-cli-discovery.md:54-57` + - `src-tauri/src/commands/ai/streaming_exec.rs:109-125 — ProcessProviderExecutor::execute` + - `src-tauri/src/commands/providers/probe.rs:105-130 — SystemProbeRunner::run` + - `src-tauri/src/provider_command.rs:43-67 — NeutralProviderCwd::new` + +### AUD-008 — Provider temp cleanup test races parallel tests + +A cleanup test snapshots all same-process probe directories while other tests create directories with the same prefix outside its local mutex\. The full Tauri suite failed once and the isolated test passed\. + +- Proof state: `reproduced` +- Reachability: `normal_path` +- Deployment: `not_deployed` — Rust validation and CI only; production cleanup behavior was not implicated\. +- Trigger: Run qa-scribe-tauri tests in parallel with the global-snapshot cleanup test overlapping another same-prefix ProbeOutputFiles test\. +- Guards and recovery: `ineffective` — The mutex in providers/tests/mod\.rs is not shared by probe/command\.rs tests; isolated rerun passing after full-suite failure confirms an inter-test observation race\. +- Disposition: `confirmed` +- Impact: `moderate` — Required CI or release validation can fail nondeterministically and normalize reruns\. +- Severity: `medium` +- Action priority: `next` +- Confidence: `high` +- Falsifier: Path-specific assertions or one shared lock across every same-prefix test would falsify the finding\. +- Sources: + - `src-tauri/src/commands/providers/probe/command.rs:92-109 — ProbeOutputFiles::new` + - `src-tauri/src/commands/providers/probe/command.rs:206-235 — probe_output_directory_and_files_are_private` + - `src-tauri/src/commands/providers/tests/mod.rs:23-38 — provider_probe_cleans_temp_files_when_spawn_fails` + - `src-tauri/src/commands/providers/tests/mod.rs:108-126 — provider_probe_temp_files` + +### AUD-009 — Structured protocol output can fall back to raw JSON + +A zero-exit structured provider run with no recognized assistant event falls back to complete raw stdout\. Nonempty protocol JSON survives content validation and can be persisted, including Summary when the selected Note has not changed\. + +- Proof state: `source_proven` +- Reachability: `failure_path` +- Deployment: `distributed` — Local structured Claude/Codex provider execution; current documented formats are recognized, so the trigger is malformed or future/unsupported protocol drift\. +- Trigger: A structured provider exits zero after emitting only malformed or unsupported nonempty event records\. +- Guards and recovery: `partial` — Line and total output are bounded, malformed events produce progress, and Summary checks the prepared Note body; executor\.rs:111-117 still falls back to raw stdout and workflow validation rejects only sanitized-empty content\. +- Disposition: `confirmed` +- Impact: `moderate` — The app can create apparently successful records containing protocol envelopes; an unchanged Note can be replaced by that content during Summary\. +- Severity: `medium` +- Action priority: `next` +- Confidence: `high` +- Falsifier: Rejecting successful structured runs with no parsed assistant text, while retaining raw fallback only for plain text, would falsify the finding\. +- Sources: + - `crates/qa-scribe-core/src/ai/executor.rs:106-117 — ProviderGenerationOutput::response_text` + - `crates/qa-scribe-core/src/ai/stream/mod.rs:73-90 — push_json_line` + - `crates/qa-scribe-core/src/generation/workflow.rs:223-264 — finish_successful_generation` + - `src-tauri/src/commands/ai/streaming_exec.rs:178-225 — StreamingProcessExecutor::execute` + +### AUD-010 — Preserve evidence off still restores source images + +The user-visible preserveEvidence preference reaches prompt text and metadata, but Testware completion restores omitted managed and external source images unconditionally\. + +- Proof state: `source_proven` +- Reachability: `normal_path` +- Deployment: `distributed` — The option is exposed in the desktop generation preflight and consumed by core generation\. +- Trigger: Uncheck Preserve evidence, generate Testware from a Note with an image, and have the provider omit that image\. +- Guards and recovery: `ineffective` — workflow\.rs:293-297 calls preserve\_managed\_attachment\_images regardless of the preference value\. +- Disposition: `confirmed` +- Impact: `moderate` — Generated Testware contradicts an explicit user choice and can retain screenshots or external Evidence intended for omission\. +- Severity: `medium` +- Action priority: `next` +- Confidence: `high` +- Falsifier: A documented advisory-only contract or conditional restoration keyed to preserveEvidence would falsify the finding\. +- Sources: + - `crates/qa-scribe-core/src/generation/preferences.rs:41-52 — TestwareGenerationPreferences` + - `crates/qa-scribe-core/src/generation/preferences.rs:197-235 — testware_preferences_prompt` + - `crates/qa-scribe-core/src/generation/workflow.rs:287-319 — finish_testware_generation` + - `frontend/src/workflows/generationPreflight.tsx:183-190` + +### AUD-011 — Rich-record patch logic is duplicated across persistence paths + +Rich body merge, validation, SQL update, and readback mechanics are repeated across Entry, conditional Entry, generated Summary, Draft, and Finding paths\. Transaction boundaries are intentionally distinct, but patch resolution and column mapping must change together\. + +- Proof state: `source_proven` +- Reachability: `normal_path` +- Deployment: `deployed` — Normal record editing and AI Summary persistence in the local Rust core\. +- Trigger: Change rich-body patch semantics, validation, or format defaults and update only some repeated paths\. +- Guards and recovery: `partial` — Shared validators, row mappers, and transactions limit drift, but no shared typed patch resolver exists and the conditional Entry update has no production caller\. +- Disposition: `hardening` +- Impact: `minor` — No current mismatch was proven, but future storage/editor changes carry synchronized-change and regression risk\. +- Severity: `low` +- Action priority: `backlog` +- Confidence: `high` +- Falsifier: One shared patch resolver used by each transaction wrapper, plus removal or coverage of the unused conditional API, would falsify the smell\. +- Sources: + - `crates/qa-scribe-core/src/services/session_service/drafts.rs:82-122 — update_draft` + - `crates/qa-scribe-core/src/services/session_service/entries.rs:83-178 — update_entry / update_entry_if_body_matches` + - `crates/qa-scribe-core/src/services/session_service/findings.rs:76-117 — update_finding` + - `crates/qa-scribe-core/src/services/session_service/generation.rs:220-285 — complete_ai_run_with_generated_note_update` + +### AUD-012 — HTML sanitizer is not quote-aware when locating tag ends + +The Rust sanitizer and projection scanner use the first raw greater-than character as a tag terminator\. A valid greater-than inside a quoted attribute can split and corrupt the tag, though allowlisting still blocks a demonstrated script-execution path\. + +- Proof state: `source_proven` +- Reachability: `normal_path` +- Deployment: `deployed` — All generated Draft, Finding, and Summary HTML passes through these scanners\. +- Trigger: Return valid allowed-tag HTML with an unescaped greater-than inside a quoted attribute, such as img alt="A \> B"\. +- Guards and recovery: `partial` — Allowed tags and safe URL schemes are rebuilt and active attributes are dropped, but outer scanners use find('\>') without quote state\. +- Disposition: `hardening` +- Impact: `minor` — Generated content and attributes can be silently corrupted; no security bypass was demonstrated\. +- Severity: `low` +- Action priority: `backlog` +- Confidence: `high` +- Falsifier: A quote-aware tokenizer/parser and regression tests for quoted delimiters would falsify the finding\. +- Sources: + - `crates/qa-scribe-core/src/generation/html_projection.rs:25-63 — HtmlPromptProjector::project` + - `crates/qa-scribe-core/src/generation/response.rs:170-209 — sanitize_editor_html_fragment` + - `crates/qa-scribe-core/src/generation/response.rs:265-350 — sanitize_opening_editor_tag` + +### AUD-013 — Final release packages are not installed or executed + +The final deb, rpm, AppImage, and DMG receive varying metadata, signature, or existence checks but are not installed or executed\. The generated APT setup deb is also not installed and its installed keyring/source files are not verified\. + +- Proof state: `invariant_only` +- Reachability: `failure_path` +- Deployment: `distributed` — These are the package formats and repository bootstrap delivered to users\. +- Trigger: Introduce a package-only dependency, loader, permission, payload, or setup-file defect that preserves currently checked metadata and signatures\. +- Guards and recovery: `partial` — Source-build E2E, metadata extraction, codesign/notarization, Gatekeeper, checksums, and mocked installer flow provide substantial coverage, but no final-format install/execute check exists\. +- Disposition: `hardening` +- Impact: `major` — A release can distribute an installer that cannot install or launch, requiring asset replacement or a corrective release\. +- Severity: `medium` +- Action priority: `next` +- Confidence: `high` +- Falsifier: Format-appropriate disposable install and smoke checks for each required package, including setup-file verification, would falsify the gap\. +- Sources: + - `.github/workflows/ci.yml:209-235` + - `.github/workflows/release.yml:301-346` + - `.github/workflows/release.yml:478-499` + - `scripts/check-apt-installer.mjs:143-268 — validateAptInstallStaging` + - `scripts/validate_linux_package_metadata.py:317-427 — validate_package` + +### AUD-014 — Required E2E cases share state and cancellation uses fixed timing + +All four critical E2E cases share one app-data directory and application instance\. The cancellation case races a provider fixture that begins output after 350 ms and completes after 1050 ms rather than waiting for a deterministic test signal\. + +- Proof state: `source_proven` +- Reachability: `normal_path` +- Deployment: `not_deployed` — Required Linux CI/release gate and observational macOS gate\. +- Trigger: Run on a loaded runner or let an earlier case fail with persistent state before the cancellation case executes\. +- Guards and recovery: `partial` — The suite is isolated from user data, uses one instance, and has explicit UI waits, but it lacks per-case reset and the fixture has fixed timers with no hold/release handshake\. +- Disposition: `confirmed` +- Impact: `moderate` — The gate can flake or become order-dependent, weakening confidence and encouraging reruns\. +- Severity: `medium` +- Action priority: `next` +- Confidence: `high` +- Falsifier: Fresh app state per case plus a cancellation fixture that blocks until explicit release or process termination would falsify the finding\. +- Sources: + - `docs/quality-scenarios.md:19-29` + - `e2e/fixtures/bin/codex:22-49` + - `e2e/specs/critical-workflows.e2e.mjs:65-153` + - `e2e/wdio.conf.mjs:9-23` + - `scripts/run-e2e.mjs:9-46` + +### AUD-015 — Code-size policy omits maintained YAML workflows + +The enforced maintained-file extensions omit yml and yaml, so the 785-line release workflow is invisible to threshold, watch-range, and dated exception checks despite documentation naming it as an operational watch item\. + +- Proof state: `source_proven` +- Reachability: `normal_path` +- Deployment: `not_deployed` — Repository maintainability policy executed in CI and release validation\. +- Trigger: Grow or modify a YAML workflow beyond policy thresholds and run code-size:check\. +- Guards and recovery: `partial` — Python and other source extensions are scanned with dated exceptions, but SOURCE\_EXTENSIONS excludes YAML entirely\. +- Disposition: `confirmed` +- Impact: `minor` — A major release-control surface can accumulate mixed responsibilities while the automated policy reports success\. +- Severity: `low` +- Action priority: `backlog` +- Confidence: `high` +- Falsifier: Scanning YAML with fixture coverage and a current split or reviewed exclusion for release\.yml would falsify the finding\. +- Sources: + - `.github/workflows/release.yml:1-785` + - `docs/code-size-guidelines.md:55-63` + - `scripts/check-code-size.mjs:7-35` + - `scripts/code-size-policy.json:5-20` + +### AUD-016 — Version bump writes can leave a partial repository state + +The version bump builds a complete plan in memory but overwrites seven files sequentially\. A later write failure leaves earlier files updated, and the next preflight refuses to continue because versions disagree\. + +- Proof state: `source_proven` +- Reachability: `failure_path` +- Deployment: `not_deployed` — Maintainer release-preparation command whose outputs feed package and release metadata\. +- Trigger: Make a later target unwritable or exhaust storage after one or more earlier writeFile calls succeed\. +- Guards and recovery: `partial` — All files are read and transformed before writes and preflight catches prior drift, but the write loop has no staging, rollback, or resumable recovery\. +- Disposition: `confirmed` +- Impact: `moderate` — Release metadata can be left inconsistent and require manual source-control recovery before the tool can run again\. +- Severity: `medium` +- Action priority: `next` +- Confidence: `high` +- Falsifier: A tested staging/rollback or explicit resumable transaction that restores consistency on write failure would falsify the finding\. +- Sources: + - `scripts/bump-version.mjs:61-92 — main` + - `scripts/bump-version.mjs:121-149 — preflightConsistencyCheck` + - `scripts/bump-version.test.mjs:189-212` + +### AUD-017 — Authoritative cargo-audit tooling is unpinned + +The shared CI/release action installs the latest available cargo-audit on every run, while other release-critical tools are pinned\. Unchanged source can therefore gain new tool behavior independently of advisory database updates\. + +- Proof state: `source_proven` +- Reachability: `normal_path` +- Deployment: `not_deployed` — Authoritative CI and tag validation environment\. +- Trigger: A new cargo-audit version is published and a subsequent validation run installs it\. +- Guards and recovery: `partial` — The audit runs locked and reconciles advisories strictly, but neither --locked nor the exception registry constrains the cargo-audit executable version\. +- Disposition: `hardening` +- Impact: `moderate` — The required gate can change or break without a repository change, reducing validation reproducibility\. +- Severity: `medium` +- Action priority: `next` +- Confidence: `high` +- Falsifier: Pinning and deliberately updating cargo-audit through reviewed tooling would falsify the finding\. +- Sources: + - `.github/actions/validate-build/action.yml:89-99` + - `docs/ci.md:28-44` + - `scripts/check-rust-audit.mjs:91-116 — run` + - `scripts/tool-versions.json:1-3` + +### AUD-R01 — Current schema initialization and migrations are retry-safe for deployed paths + +The audit did not find an application path that stamps the current schema version before all feature-detecting helpers and foreign-key checks succeed\. Current-version drift requires out-of-band local mutation, while interrupted older migrations retain the old version and retry\. + +- Proof state: `source_proven` +- Reachability: `failure_path` +- Deployment: `deployed` — SQLite initialization on desktop startup\. +- Trigger: Interrupt an older migration or externally corrupt a database while retaining its current user\_version\. +- Guards and recovery: `effective` — Destructive helpers use immediate transactions, helpers detect existing features, foreign keys are checked, and user\_version is written last; newer versions are rejected\. +- Disposition: `refuted` +- Impact: `none` — No reachable app-controlled incomplete-current schema or non-retryable migration was demonstrated\. +- Severity: `informational` +- Action priority: `none` +- Confidence: `high` +- Falsifier: A migration step that is neither transactional nor idempotent, or a path that stamps current before checks complete, would reopen the candidate\. +- Sources: + - `crates/qa-scribe-core/src/storage/mod.rs:72-144 — initialize / migrate` + - `crates/qa-scribe-core/src/storage/mod.rs:252-326 — migration helpers` + - `crates/qa-scribe-core/tests/session_storage/migrations/integrity.rs:1-37` + +### AUD-R02 — useStableCapability has no demonstrated reachable stale-context failure + +The Proxy-backed capability pattern is unusual, but critical mutable state uses refs and write versions, and no concrete UI or native event was shown to invoke a stale context after commit but before its passive effect update\. + +- Proof state: `unverified` +- Reachability: `unknown` +- Deployment: `distributed` — Shared frontend action-factory mechanism\. +- Trigger: Hypothetical action invocation after a render commits but before the context ref passive effect runs\. +- Guards and recovery: `partial` — Proxy reads occur at invocation time, critical state has refs and write versions, and lifecycle tests cover several stale async paths; no concrete trigger was established\. +- Disposition: `refuted` +- Impact: `none` — No evidenced product impact; unusual implementation alone is not an actionable smell\. +- Severity: `informational` +- Action priority: `none` +- Confidence: `medium` +- Falsifier: A reproducible handler or native event reading previous context in the post-commit window would reopen the candidate\. +- Sources: + - `frontend/src/app/useAppController.lifecycle.test.ts:12-76` + - `frontend/src/app/useSessionWorkspace.ts:18-34 — workspace refs` + - `frontend/src/app/useStableCapability.ts:8-26 — useStableCapability` + +### AUD-R03 — Tauri command and capability surfaces are aligned for the current trust model + +Build registration, Specta handler and bindings, default permission, generated permissions, and the main-window capability agree\. The single bundled-code WebView model does not justify splitting the current command capability without a new principal boundary\. + +- Proof state: `reproduced` +- Reachability: `normal_path` +- Deployment: `distributed` — Production main WebView capability in the packaged desktop app\. +- Trigger: Run the repository command-surface checker and assess authorization against the current single-principal threat model\. +- Guards and recovery: `effective` — The checker reported 33 aligned commands; the capability targets main, withGlobalTauri is false, and no arbitrary WebView path or executable command was found\. +- Disposition: `refuted` +- Impact: `none` — No command drift or missing current principal boundary was found\. +- Severity: `informational` +- Action priority: `none` +- Confidence: `high` +- Falsifier: A mismatched command representation, remote/multi-principal content, broader production permission, or arbitrary path/process command would reopen the candidate\. +- Sources: + - `docs/tauri-threat-model.md:5-23` + - `frontend/src/bindings.ts:5-48 — commands` + - `src-tauri/build.rs:8-47 — COMMANDS` + - `src-tauri/capabilities/default.json:1-7` + - `src-tauri/permissions/default.toml:3-39` + - `src-tauri/src/specta_bindings.rs:39-85 — builder` + +### AUD-R04 — E2E promotion markers enforce platform-specific first-attempt history + +The promotion checker separates Linux and macOS names, derives markers from the actual E2E step outcome, deduplicates run IDs, rejects failures and attempts other than one, and paginates through unrelated artifacts\. + +- Proof state: `source_proven` +- Reachability: `normal_path` +- Deployment: `not_deployed` — CI evidence used for platform promotion decisions\. +- Trigger: Audit artifact history containing unrelated items, mixed platforms, failures, and reruns\. +- Guards and recovery: `effective` — Outcome-derived names, platform prefixes, pagination, newest-by-run deduplication, and runAttempt checks implement the documented metric\. +- Disposition: `refuted` +- Impact: `none` — The proposed marker-integrity bypass was not present\. +- Severity: `informational` +- Action priority: `none` +- Confidence: `high` +- Falsifier: A path that uploads matching passed names after failed E2E or counts duplicate runs/attempts would reopen the candidate\. +- Sources: + - `.github/actions/run-built-app-e2e/action.yml:29-86` + - `scripts/check-e2e-reliability.mjs:21-74 — assessReliability / listArtifacts` + - `scripts/check-e2e-reliability.test.mjs:15-92` + +### AUD-R05 — Failed APT staging cannot partially deploy to Pages + +Although APT output is built incrementally, it is confined to a fresh runner staging directory\. Artifact upload and Pages deployment require preceding signing, setup, validation, release-asset publication, and monotonic checks to succeed\. + +- Proof state: `source_proven` +- Reachability: `failure_path` +- Deployment: `not_deployed` — Release runner staging before live Pages deployment\. +- Trigger: Fail signing, setup-package construction, installer validation, or artifact upload after staging writes begin\. +- Guards and recovery: `effective` — set -e staging, downstream job dependencies, and monotonic checks prevent the partial runner tree from becoming the live repository\. +- Disposition: `refuted` +- Impact: `none` — A staging failure leaves ephemeral files only and cannot update Pages\. +- Severity: `informational` +- Action priority: `none` +- Confidence: `high` +- Falsifier: In-place live writes or deploy behavior after failed staging would reopen the candidate\. +- Sources: + - `.github/workflows/release.yml:429-570` + - `.github/workflows/release.yml:735-770` + - `scripts/build_apt_repository.py:473-523 — build_repository` + - `scripts/check-apt-monotonic.mjs:80-106 — checkMonotonic` + +## Remediation + +### AUD-001 — Recovered Summary completion can be overwritten by stale frontend state + +Reconcile recovered completion by action type, reload or revision-check the active Note, preserve dirty local edits, and make Note saves conflict-aware\. + +### AUD-002 — Session and output-library navigation lack latest-intent ordering + +Introduce latest-intent request ordering for Session and library navigation without adding a global state framework\. + +### AUD-003 — Provider discovery requests can overwrite newer observations + +Centralize provider observation ownership and apply monotonic request/depth ordering while retaining independent catalog and default lifecycles\. + +### AUD-004 — Blank Session titles bypass dirty-state protection + +Treat every title difference as pending, validate required text explicitly, and never label an invalid title as autosaved\. + +### AUD-005 — Managed attachment previews are re-read on editor updates + +Cache previews by attachment identity, deduplicate in-flight reads, and hydrate only nodes whose identity or resolved source changed\. + +### AUD-006 — Generation cancellation is not authoritative across readiness and persistence + +Pass per-job cancellation through readiness, check before persistence, and enforce legal state transitions with deterministic race tests\. + +### AUD-007 — Neutral provider directory creation fails open + +Return an actionable error or create another guaranteed-private directory; never execute providers in the shared temp root\. + +### AUD-008 — Provider temp cleanup test races parallel tests + +Assert cleanup for the exact temporary path created by the test, avoiding process-global before/after snapshots\. + +### AUD-009 — Structured protocol output can fall back to raw JSON + +Make output format explicit in response\_text and fail structured runs without recognized assistant content using an actionable compatibility error\. + +### AUD-010 — Preserve evidence off still restores source images + +Define the preference contract explicitly and skip deterministic restoration when false while still sanitizing provider-returned images\. + +### AUD-011 — Rich-record patch logic is duplicated across persistence paths + +Extract only shared rich-body resolution and column mapping; retain distinct transaction orchestration and stale-write guards\. + +### AUD-012 — HTML sanitizer is not quote-aware when locating tag ends + +Replace first-delimiter scanning with a quote-aware tokenizer or maintained HTML fragment parser, preserving the current allowlist\. + +### AUD-013 — Final release packages are not installed or executed + +Add post-build package smoke jobs: install deb/rpm in disposable environments, execute AppImage, mount/copy/launch the DMG app, and install/verify the actual setup deb\. + +### AUD-014 — Required E2E cases share state and cancellation uses fixed timing + +Split or reset cases into isolated app sessions and make the cancellation fixture deterministic rather than time-raced\. + +### AUD-015 — Code-size policy omits maintained YAML workflows + +Add yml/yaml to maintained-file scanning and apply the existing dated exception model to the release workflow\. + +### AUD-016 — Version bump writes can leave a partial repository state + +Stage all outputs, replace atomically where possible, and roll back already-replaced files on failure; inject write failures in tests\. + +### AUD-017 — Authoritative cargo-audit tooling is unpinned + +Add cargo-audit to the reviewed tool-version source and install that exact version in shared validation\. diff --git a/docs/codebase-audit-validation-2026-07-20.md b/docs/codebase-audit-validation-2026-07-20.md new file mode 100644 index 0000000..5088fe4 --- /dev/null +++ b/docs/codebase-audit-validation-2026-07-20.md @@ -0,0 +1,67 @@ +# Codebase Audit Validation Evidence + +This record makes the audit's renderer and command evidence inspectable during +independent review. It is evidence for the audit artifacts, not a replacement +for the strict ledger in `docs/codebase-audit-2026-07-20.json`. + +## Canonical Render + +`flow_audit_render` accepted the exact ledger with `status: ok` and returned: + +- total: 22 +- actionable: 17 +- remediation items: 17 +- dispositions: confirmed=13, hardening=4, refuted=5 +- severities: high=1, medium=13, low=3, informational=5 +- priorities: next=14, backlog=3, none=5 +- proof states: reproduced=2, source_proven=18, invariant_only=1, unverified=1 + +The complete returned canonical Markdown was written unchanged to +`docs/codebase-audit-2026-07-20.md`. Its `` marker and +derived summary match the JSON source of truth. + +## Runtime-Attested Checks + +Artifact reconciliation command: + +```sh +node -e "const fs=require('fs'); const ledger=JSON.parse(fs.readFileSync('docs/codebase-audit-2026-07-20.json','utf8')); const md=fs.readFileSync('docs/codebase-audit-2026-07-20.md','utf8'); if(ledger.version!=='audit-ledger/v1'||ledger.findings.length!==22||!md.includes('')||!md.includes('Total findings: 22')) throw new Error('audit artifact mismatch'); console.log('audit artifacts valid: 22 findings')" +``` + +Observed outcome: passed with `audit artifacts valid: 22 findings`. + +Validation receipt: `sha256:8792fc8e1330634c80091181c0e9567af3e8d9fb2d19039033c0f6d4441c9fdb`. + +Focused project command: + +```sh +bun run frontend:test && cargo test -p qa-scribe-core && cargo test -p qa-scribe-tauri jobs::tests && bun run release:script-tests && bun run linux:metadata:test && bun run tauri:commands:check && bun run code-size:check +``` + +Observed outcome: passed. The run included 180 frontend tests, 177 core tests, +5 targeted JobStore tests, 65 release-script tests, 5 Linux metadata/archive +tests, the 33-command Tauri surface check, and the code-size policy check. + +Validation receipt: `sha256:ad8fc3d91c39c3f6f05c6b33dc90e79e6c36016f5fcb7a06e0a1fdfc1631d0c6`. + +## Reproduced Claims + +`AUD-008` was observed during the bounded runtime audit: the full +`cargo test -p qa-scribe-tauri` run reported 116 passed, 1 failed, and 2 ignored. +The failure was +`commands::providers::tests::provider_probe_cleans_temp_files_when_spawn_fails`; +the exact isolated rerun passed. Source inspection showed that the failing test's +mutex is not shared by same-prefix temporary-directory tests in +`commands/providers/probe/command.rs`, which establishes the inter-test race +rather than a production cleanup defect. + +`AUD-R03` was reproduced both during the bounded runtime audit and in the +runtime-attested focused project command. `bun run tauri:commands:check` passed +with `33 commands and the main-window core permissions agree`. + +## Scope Limits + +No packaged installer was available for installation testing, no authenticated +provider command was used, and built-application E2E was not rerun for this +report. The corresponding ledger entries are therefore calibrated as source +proof or hardening gaps rather than reproduced product failures. diff --git a/docs/codebase-remediation-plan-2026-07-20.md b/docs/codebase-remediation-plan-2026-07-20.md new file mode 100644 index 0000000..1093553 --- /dev/null +++ b/docs/codebase-remediation-plan-2026-07-20.md @@ -0,0 +1,332 @@ +# Codebase Remediation Plan + +Date: 2026-07-20. Evidence boundary: +`docs/codebase-audit-2026-07-20.json`. + +The canonical renderer result and runtime-attested audit checks are recorded in +`docs/codebase-audit-validation-2026-07-20.md`. The prerequisite +`evidence-backed-codebase-audit` Flow feature passed its independent detailed +review before this plan was created. + +## Outcome + +Resolve the audit's confirmed correctness and robustness risks first, restore +trust in validation and release tooling, then make bounded complexity reductions +without changing the SQLite schema, Tauri command names, generated binding +compatibility, provider authentication assumptions, or authoritative product +language. + +This document is an implementation plan, not authorization to edit product +code. Each slice requires a later approved Flow feature, source-bound +validation, and independent review. + +## Ordering Principles + +1. Repair nondeterministic or moving validation before relying on those gates + for behavioral refactors. +2. Protect authored Session data and user intent before performance or + maintainability work. +3. Keep frontend request ordering local to the capability that owns it; do not + introduce a global store or reducer. +4. Preserve process-tree cleanup, output bounds, transaction boundaries, + sanitization allowlists, and stale-write guards while fixing adjacent logic. +5. Treat package installation coverage as release hardening, not proof that a + current package is broken. +6. Implement low-priority cleanup only after the related correctness slices are + stable and covered. + +## Wave 0: Trustworthy Gates + +### RP-01: Make provider temporary-file tests deterministic + +- Findings: `AUD-008`. +- Outcome: Tauri provider cleanup tests assert only paths they own and cannot + fail because another parallel test created a same-prefix directory. +- Targets: `src-tauri/src/commands/providers/tests/mod.rs`, + `src-tauri/src/commands/providers/probe/command.rs`, and focused tests. +- Depends on: none. +- Review depth: standard. +- Validation: repeatedly run the previously failing full + `cargo test -p qa-scribe-tauri` suite and the focused probe tests; do not + accept rerun-until-green behavior. +- Non-goals: no production cleanup-policy or provider-protocol changes. + +### RP-02: Isolate E2E workflows and make cancellation deterministic + +- Findings: `AUD-014`. +- Outcome: every critical workflow starts from independent application data, + and the fake provider waits for an explicit cancellation/release condition + instead of a 350-1050 ms race. +- Targets: `scripts/run-e2e.mjs`, `e2e/wdio.conf.mjs`, + `e2e/specs/critical-workflows.e2e.mjs`, `e2e/fixtures/bin/codex`, and E2E + reliability checks. +- Depends on: none. +- Review depth: detailed because this changes the required release gate. +- Validation: source isolation check, repeated first-attempt built-app E2E runs, + forced failure of one case proving no later-case contamination, and production + frontend restoration. +- Non-goals: no additional production Tauri permissions or real-provider calls. + +### RP-03: Pin the audit executable used by authoritative validation + +- Findings: `AUD-017`. +- Outcome: CI and tag validation install a reviewed exact `cargo-audit` version + from the repository's tool-version source. +- Targets: `scripts/tool-versions.json`, + `.github/actions/validate-build/action.yml`, Rust-audit script tests, and + `docs/ci.md`. +- Depends on: none. +- Review depth: standard. +- Validation: installer/unit tests, `bun run rust:audit`, workflow static checks, + and a clean shared validation-action invocation. +- Non-goals: no advisory-registry or Rust dependency changes in the same slice. + +## Wave 1: Data And Intent Integrity + +### RP-04: Reconcile recovered Summary completion safely + +- Findings: `AUD-001`. +- Outcome: recovered Summary completion cannot leave an editable stale Note or + overwrite generated content through a later autosave; dirty local edits remain + recoverable. +- Targets: `frontend/src/app/generationActions.ts`, Session workspace/save + ownership, the Tauri job result/status contract only if required, and focused + lifecycle/autosave tests. +- Depends on: `RP-02` for deterministic cross-layer coverage. +- Review depth: detailed because authored Note content is at risk. +- Validation: deterministic ordering tests for hydrate-before-completion and + completion-before-hydrate, dirty-local-edit preservation, restart + reconciliation, focused frontend tests, Rust Summary stale-write tests, and + one built-app recovery scenario. +- Non-goals: no global frontend state framework and no SQLite schema migration + unless a separate architecture decision proves revision storage necessary. + +### RP-05: Make blank Session-title state explicit and protected + +- Findings: `AUD-004`. +- Outcome: clearing a Session title produces a visible validation state and + cannot be silently discarded or labelled autosaved during navigation or + close. +- Targets: `frontend/src/app/sessionActions.ts`, + `frontend/src/app/useAppController.ts`, + `frontend/src/app/usePendingChangeProtection.ts`, Session editor feedback, + and close/autosave tests. +- Depends on: `RP-04` because both change Session save coordination. +- Review depth: detailed because close protection and authored state interact. +- Validation: focused blank/whitespace autosave, navigation, browser unload, + native close, save-failure, discard, and restoration tests. +- Non-goals: no backend relaxation of required Session titles. + +### RP-06A: Enforce latest-intent ordering for Session navigation + +- Findings: `AUD-002`. +- Outcome: stale Session and cross-Session library responses cannot replace a + newer navigation choice. +- Targets: `frontend/src/app/sessionActions.ts`, + `frontend/src/app/useOutputLibraries.ts`, linked library navigation in the app + controller, and focused race tests. +- Depends on: `RP-05` to avoid overlapping Session-controller edits. +- Review depth: detailed because navigation crosses Session save and Record + hydration ownership. +- Validation: reversed-promise-order tests for Session opens and library loads; + verify operation-scoped busy/error state, focused Record hydration, and latest + navigation intent. +- Non-goals: no generic request framework, provider-discovery changes, or + application-wide reducer. + +### RP-06B: Enforce monotonic provider-discovery ordering + +- Findings: `AUD-003`. +- Outcome: older or shallower provider observations cannot replace newer deep + discovery results. +- Targets: `frontend/src/hooks/useSettingsController.ts`, startup provider + loading, Settings discovery, generation preflight refresh, and focused tests. +- Depends on: `RP-03` so the provider validation environment is reproducible. +- Review depth: standard because ownership stays inside provider observation + state and no Rust provider contract changes are planned. +- Validation: reversed-promise-order tests for fast-versus-deep startup, + automatic-versus-manual discovery, and repeated refresh; verify independent + catalog/default lifecycle and retained last-good snapshots. +- Non-goals: no Session navigation changes, shared request framework, or global + reducer. + +### RP-07: Make generation cancellation a legal state machine + +- Findings: `AUD-006`. +- Outcome: per-job cancellation reaches readiness, is rechecked before final + persistence, and cannot transition from Cancelling to Running or Completed. +- Targets: `src-tauri/src/jobs.rs`, `src-tauri/src/commands/ai/job_runner.rs`, + provider readiness/probe cancellation plumbing, and deterministic race tests. +- Depends on: `RP-01` for a stable Tauri suite. +- Review depth: detailed because process lifecycle and persistent records cross. +- Validation: cancel during preparation, readiness, child startup, streaming, + post-provider/pre-persistence, and shutdown; assert legal terminal states, + process-tree cleanup, no generated record after accepted cancellation, and + retained status behavior. +- Non-goals: no weakening of watchdog, output, or process-group controls. + +### RP-08: Fail closed when neutral provider scope cannot be created + +- Findings: `AUD-007`. +- Outcome: every provider subprocess runs in a newly owned private directory or + returns an actionable error before execution. +- Targets: `src-tauri/src/provider_command.rs`, provider probe/execution callers, + command error mapping, and failure-injection tests. +- Depends on: `RP-01`, `RP-07`; cancellation plumbing lands first because both + slices touch provider readiness and execution callers. +- Review depth: detailed because this enforces an accepted privacy boundary. +- Validation: private permissions, creation-failure injection, no subprocess + spawn on failure, successful owned-directory cleanup, and provider probe plus + generation tests. +- Non-goals: no change to provider PATH or authentication ownership. + +### RP-09: Make structured generation and Evidence preferences truthful + +- Findings: `AUD-009`, `AUD-010`. +- Outcome: structured providers without recognized assistant content fail with + a compatibility error, while `preserveEvidence=false` actually suppresses + deterministic restoration of omitted source images. +- Targets: core output-format/result types, stream parsers, + `generation/workflow.rs`, Testware preferences, generated command bindings if + the contract changes, and focused generation tests. +- Depends on: none. +- Review depth: detailed because Summary can replace a Note and Evidence intent + affects persisted Testware. +- Validation: unknown/malformed structured events, current Claude/Codex fixtures, + plain-text fallback, unchanged/stale Summary paths, preserve Evidence true and + false with managed/external images, sanitization, bindings, and workspace + tests. +- Non-goals: no live-provider requirement in deterministic CI and no provider + protocol guesswork beyond sanitized fixtures. + +## Wave 2: Performance And Delivery Robustness + +### RP-10: Cache managed previews outside the typing path + +- Findings: `AUD-005`. +- Outcome: each attachment preview is loaded once per identity/change and + duplicate or stale IPC reads are bounded during editor updates. +- Targets: `frontend/src/editor/RichTextEditor.tsx`, + `frontend/src/editor/editorHtml.ts`, cache lifecycle ownership, and editor + tests. +- Depends on: none. +- Review depth: standard. +- Validation: command call-count tests across keystrokes, duplicate images, + in-flight deduplication, attachment replacement/removal, failure/retry, stale + response rejection, and editor behavior preservation. +- Non-goals: no new global cache or attachment persistence format. + +### RP-11: Install and exercise final release artifacts + +- Findings: `AUD-013`. +- Outcome: every required final package format receives a format-appropriate + disposable install or execution smoke, and the actual APT setup package's + installed files are verified. +- Targets: package workflows/actions, Linux and macOS package scripts, package + test helpers, and `docs/ci.md`/release documentation. +- Depends on: `RP-02` so packaged smoke can reuse deterministic behavior without + weakening production isolation. +- Review depth: detailed because release and platform boundaries change. +- Validation: install and launch deb/rpm, execute AppImage, mount/copy/launch the + DMG app, install the setup deb, verify keyring/source permissions and content, + and preserve signing/notarization checks. +- Non-goals: no publication, signing-secret changes, or expansion of release + privileges in the implementation feature. + +### RP-12: Make version bumps recover atomically + +- Findings: `AUD-016`. +- Outcome: a failed write cannot leave version-bearing files inconsistent, and + the tool has tested recovery behavior. +- Targets: `scripts/bump-version.mjs`, its tests, and release documentation. +- Depends on: none. +- Review depth: standard. +- Validation: injected failures at every replacement position, rollback or + resumable recovery, unchanged dry-run/idempotency behavior, release metadata + check, and cross-platform path handling. +- Non-goals: no version bump or release publication while implementing the fix. + +## Backlog: Evidence-Gated Complexity Reduction + +### RP-13: Bring YAML workflows under the existing size policy + +- Findings: `AUD-015`. +- Outcome: maintained YAML is measured, and the release workflow is either + split along a proven responsibility seam or given a current dated exclusion. +- Targets: `scripts/check-code-size.mjs`, policy fixtures, + `scripts/code-size-policy.json`, `.github/workflows/release.yml`, and + `docs/code-size-guidelines.md`. +- Depends on: `RP-11` so package/release behavior has stronger protection before + structural workflow edits. +- Review depth: detailed if the workflow is split; standard for scanner plus a + reviewed exclusion only. +- Validation: YAML scanner fixtures, actionlint, zizmor, release-script tests, + and equivalence of job permissions, dependencies, artifacts, and conditions. +- Non-goals: no mechanical workflow split solely to reduce line count. + +### RP-14: Consolidate rich-record patch resolution + +- Findings: `AUD-011`. +- Outcome: Entry, Draft, Finding, and generated Summary paths share typed rich- + body patch resolution while retaining their distinct transaction and stale- + write semantics. +- Targets: core SessionService Entry/Draft/Finding/generation modules and their + tests. +- Depends on: `RP-04`, `RP-09` so behavior fixes land before structural cleanup. +- Review depth: detailed because persistence transactions are touched. +- Validation: focused update/clear/default-format tests for each Record type, + Summary stale-write and rollback tests, SQLite integration tests, clippy, and + broad workspace tests. +- Non-goals: no repository pattern, generic Record abstraction, schema change, + or merging of intentionally distinct transaction orchestration. + +### RP-15: Replace delimiter scanning with quote-aware HTML parsing + +- Findings: `AUD-012`. +- Outcome: valid quoted delimiters survive generated HTML sanitization and + projection without weakening the existing tag, attribute, or URL allowlist. +- Targets: core generation response/projection HTML modules and focused tests. +- Depends on: `RP-09` because both change generated-output handling. +- Review depth: detailed because sanitization is a security and data-integrity + boundary. +- Validation: quoted delimiter, malformed/nested tag, multibyte, URL-scheme, + event-attribute, managed-image, projection, and property/fuzz-style cases; + broad core tests afterward. +- Non-goals: no broader HTML feature support and no frontend-only trust shift. + +## Traceability Check + +All actionable ledger findings are planned exactly once: + +| Finding | Slice | +| --- | --- | +| `AUD-001` | `RP-04` | +| `AUD-002` | `RP-06A` | +| `AUD-003` | `RP-06B` | +| `AUD-004` | `RP-05` | +| `AUD-005` | `RP-10` | +| `AUD-006` | `RP-07` | +| `AUD-007` | `RP-08` | +| `AUD-008` | `RP-01` | +| `AUD-009`, `AUD-010` | `RP-09` | +| `AUD-011` | `RP-14` | +| `AUD-012` | `RP-15` | +| `AUD-013` | `RP-11` | +| `AUD-014` | `RP-02` | +| `AUD-015` | `RP-13` | +| `AUD-016` | `RP-12` | +| `AUD-017` | `RP-03` | + +Refuted findings `AUD-R01` through `AUD-R05` are intentionally excluded from +implementation. Reopen them only if their ledger falsifiers become true. + +## Completion Gate For A Future Implementation Session + +- Every implemented slice passes its focused behavioral or preservation checks. +- `bun run verify:fast` passes between slices. +- Boundary, persistence, release, package, security, and completed-wave changes + run `bun run verify` plus any platform/package checks named above. +- No new lint, size, audit, security, or compatibility exception is introduced + without a narrow rationale and review trigger. +- The final implementation session receives an independent detailed review and + explicitly closes only after broad validation passes. diff --git a/docs/quality-scenarios.md b/docs/quality-scenarios.md index 9d2caf0..29e4036 100644 --- a/docs/quality-scenarios.md +++ b/docs/quality-scenarios.md @@ -34,7 +34,7 @@ Validation: bun run e2e ``` -The command builds the opt-in E2E binary, restores a production frontend build before running it, writes run metadata and failure evidence under `artifacts/e2e/`, and removes its temporary application data. Run `bun run e2e:isolation` after a production frontend build to prove the WDIO guest, permissions, and plugins are absent from production. +The command builds the opt-in E2E frontend into its temporary root, points the E2E Tauri config at that isolated output, writes run metadata and failure evidence under `artifacts/e2e/`, and removes its temporary application data. It never replaces `frontend/dist`. Run `bun run e2e:isolation` after a production frontend build to prove the WDIO guest, permissions, and plugins are absent from production. ## Maintainability diff --git a/docs/releasing.md b/docs/releasing.md index d3c1de9..4c80370 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -45,6 +45,22 @@ notes — fill those in, then re-run the check: node scripts/check-release-metadata.mjs --expected-tag v1.0.0 ``` +Before replacing anything, the bump script writes an on-disk +`.qa-scribe-version-transaction.json` manifest, then stages all seven new +outputs and a rollback copy beside each destination. Same-directory renames +make each file replacement atomic on the supported release hosts. If the +process is terminated between replacements, the next invocation, including a +dry run, reads the manifest before preflight and restores the prior consistent +version. If the committed phase was recorded before termination, it keeps the +new version and finishes transaction-file cleanup instead. Synchronous replacement failures still roll +back immediately; an incomplete rollback leaves the manifest and copies for +automatic retry on the next invocation. The script also refuses to overwrite a +destination changed during staging, immediately before replacement, or after an +interrupted replacement. Run the +bump only in a dedicated checkout with no concurrent repository writers: +ordinary filesystem renames do not provide compare-and-swap coordination with +another process editing between system calls. + `frontend/bun.lock` does not need updating: it only records third-party dependency versions, not the frontend workspace's own `version` field. `Cargo.lock` does need to stay in sync with the app version because the Rust @@ -181,6 +197,15 @@ The release workflow: - validates the prebuilt frontend contains `index.html` and non-empty CSS assets before Tauri consumes it - builds Linux output as `.deb`, `.rpm`, and AppImage through Tauri - validates Linux package metadata and builds a signed APT repository +- installs and launches the final deb and executes the AppImage on the + disposable Ubuntu runner +- mounts the final artifacts read-only in a digest-pinned Fedora 43 container, + installs the RPM through dependency-resolving `dnf` before adding the Xvfb + launch harness, and launches it there +- installs the actual generated APT setup deb, checks its installed keyring and + source content/permissions/ownership, and purges it +- mounts each final DMG, copies the app bundle out, and launches the copied app + after signing/notarization and before upload - stages `install-apt-repo.sh` with the pinned APT signing key fingerprint and validates the rendered installer keeps that effective fingerprint - publishes public APT bootstrap assets in the APT Pages artifact for installer-side signature verification - uploads macOS and Linux release assets @@ -230,4 +255,22 @@ node scripts/package-tauri-linux.mjs python3 scripts/validate_linux_package_metadata.py "dist/rust/artifacts/*.deb" "dist/rust/artifacts/*.rpm" node scripts/check-apt-repository.mjs node scripts/check-apt-installer.mjs +node scripts/smoke-release-artifacts.mjs --linux-deb-appimage dist/rust/artifacts +docker run --rm \ + --volume "${PWD}:/workspace:ro" \ + --volume "$(command -v bun):/usr/local/bin/bun:ro" \ + --workdir /workspace \ + fedora:43@sha256:781b7642e8bf256e9cf75d2aa58d86f5cc695fd2df113517614e181a5eee9138 \ + bun scripts/smoke-release-artifacts.mjs --linux-rpm dist/rust/artifacts +``` + +The Ubuntu smoke installs and purges the deb with `sudo`; use it only on a +disposable Linux machine where `qa-scribe` is not already installed. The RPM +smoke installs inside its disposable Fedora container. The APT setup mode is run by the release workflow after the signed +repository builder creates the real setup package and exported keyring. + +After producing a signed/notarized DMG on a disposable macOS host, run: + +```bash +node scripts/smoke-release-artifacts.mjs --macos-dmg dist/rust/artifacts ``` diff --git a/docs/tauri-threat-model.md b/docs/tauri-threat-model.md index 637a514..7fe42cb 100644 --- a/docs/tauri-threat-model.md +++ b/docs/tauri-threat-model.md @@ -32,7 +32,7 @@ The built-application suite deliberately adds a more privileged automation bound - production configuration keeps `withGlobalTauri` disabled and the production capability contains no WDIO permission; - the feature-gated application-data override points tests at an explicit temporary directory, while normal binaries always use Tauri's application-data path; - the provider fixture is local and deterministic, leads the isolated E2E process PATH for Fast readiness, also has a feature-gated Deep-resolution override, and requires no credentials or network access; -- the runner restores a production frontend build, deletes temporary data, and retains only test logs/measurements under the ignored `artifacts/` directory. +- the runner keeps E2E assets in its temporary root, deletes temporary data, and retains only test logs/measurements under the ignored `artifacts/` directory; the shared action builds and checks production assets independently. `bun run e2e:isolation` checks the independent Rust, frontend, configuration, capability, and compiled-bundle switches. This defense-in-depth is required because the WDIO plugin can execute scripts inside the WebView. Adding E2E to another platform requires rerunning this review; it does not justify broadening the production command or capability surface. @@ -52,8 +52,9 @@ request. It does not expose data or execution authority; its availability impact is limited to terminating the same main window. The explicit temporary app-data directory remains authoritative on macOS; isolated `HOME` and `TMPDIR` values also keep provider and temporary state away from runner defaults. The -runner restores the production frontend and reruns `bun run e2e:isolation` -before retaining evidence. +runner builds the E2E frontend in an isolated temporary directory. The shared +action independently builds the production frontend and reruns +`bun run e2e:isolation` before retaining evidence. macOS evidence uses a separate `qa-scribe-e2e-macos-*` artifact namespace. The job is observational: GitHub Actions marks its failures as non-blocking, and diff --git a/e2e/fixtures/bin/codex b/e2e/fixtures/bin/codex index 3ad40e3..b6c0292 100755 --- a/e2e/fixtures/bin/codex +++ b/e2e/fixtures/bin/codex @@ -1,9 +1,40 @@ #!/usr/bin/env node import process from 'node:process' +import { existsSync, mkdirSync, watch, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' const args = process.argv.slice(2) +function reserveInvocation(controlDirectory) { + for (let index = 1; index <= 100; index += 1) { + try { + mkdirSync(join(controlDirectory, `codex-exec-${index}.lock`)) + return index + } catch (error) { + if (error?.code !== 'EEXIST') throw error + } + } + throw new Error('Codex fixture exceeded its coordinated invocation limit') +} + +function waitForFile(path) { + if (existsSync(path)) return Promise.resolve() + return new Promise((resolve, reject) => { + const watcher = watch(dirname(path), () => { + if (existsSync(path)) { + watcher.close() + resolve() + } + }) + watcher.on('error', reject) + if (existsSync(path)) { + watcher.close() + resolve() + } + }) +} + if (args[0] === 'app-server') { let buffered = '' process.stdin.setEncoding('utf8') @@ -25,27 +56,38 @@ if (args[0] === 'app-server') { process.stdin.on('data', (chunk) => { prompt += chunk }) - process.stdin.on('end', () => { - const isTestware = /Create test scenarios with test cases from the selected note only/i.test(prompt) - const isSummary = /Keep the output as a summarized QA note/i.test(prompt) - const html = isTestware - ? '

E2E test cases

  • Deterministic generated case
' - : isSummary - ? '

E2E generated summary.

' - : '

E2E finding

Deterministic provider output.

' - const marker = prompt.match(//i)?.[0]?.slice(1, -1) - const response = marker ? `<${marker}>${html}` : html + process.stdin.on('end', async () => { + try { + const controlDirectory = process.env.QA_SCRIBE_E2E_FIXTURE_DIR + if (!controlDirectory) throw new Error('QA_SCRIBE_E2E_FIXTURE_DIR is required by the deterministic Codex fixture') + mkdirSync(controlDirectory, { recursive: true }) + const invocation = reserveInvocation(controlDirectory) + const startedPath = join(controlDirectory, `codex-exec-${invocation}.started`) + const releasePath = join(controlDirectory, `codex-exec-${invocation}.release`) - process.stdout.write(`${JSON.stringify({ type: 'turn.started' })}\n`) - setTimeout(() => { - process.stdout.write(`${JSON.stringify({ type: 'item/agentMessage/delta', delta: response.slice(0, Math.ceil(response.length / 2)) })}\n`) - }, 350) - setTimeout(() => { - process.stdout.write(`${JSON.stringify({ type: 'item/agentMessage/delta', delta: response.slice(Math.ceil(response.length / 2)) })}\n`) - }, 700) - setTimeout(() => { - process.stdout.write(`${JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: response } })}\n`) - }, 1_050) + const isTestware = /Create test scenarios with test cases from the selected note only/i.test(prompt) + const isSummary = /Keep the output as a summarized QA note/i.test(prompt) + const html = isTestware + ? '

E2E test cases

  • Deterministic generated case
' + : isSummary + ? '

E2E generated summary.

' + : '

E2E finding

Deterministic provider output.

' + const marker = prompt.match(//i)?.[0]?.slice(1, -1) + const response = marker ? `<${marker}>${html}` : html + const midpoint = Math.ceil(response.length / 2) + + writeFileSync(startedPath, `${process.pid}\n`, { flag: 'wx' }) + process.stdout.write(`${JSON.stringify({ type: 'turn.started' })}\n`) + await waitForFile(releasePath) + process.stdout.write(`${JSON.stringify({ type: 'item/agentMessage/delta', delta: response.slice(0, midpoint) })}\n`) + setImmediate(() => { + process.stdout.write(`${JSON.stringify({ type: 'item/agentMessage/delta', delta: response.slice(midpoint) })}\n`) + process.stdout.write(`${JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: response } })}\n`) + }) + } catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + } }) } else if (args[0] === 'debug' && args[1] === 'models') { process.stdout.write('{"models":[]}\n') diff --git a/e2e/specs/critical-workflows.e2e.mjs b/e2e/specs/critical-workflows.e2e.mjs index 39ff7f5..e3c8ac4 100644 --- a/e2e/specs/critical-workflows.e2e.mjs +++ b/e2e/specs/critical-workflows.e2e.mjs @@ -1,8 +1,12 @@ import assert from 'node:assert/strict' +import { existsSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' const primaryTitle = 'E2E primary session' const secondaryTitle = 'E2E secondary session' const primaryNote = 'Primary note persisted across Session switching.' +const scenario = process.env.QA_SCRIBE_E2E_SCENARIO +const fixtureDirectory = process.env.QA_SCRIBE_E2E_FIXTURE_DIR async function button(label) { return $(`button=${label}`) @@ -62,93 +66,157 @@ async function createSessionFixture(titleValue, noteValue) { return { newSession, note, title } } -describe('QA Scribe built application', () => { - it('creates, edits, switches, reopens, and deletes Sessions with persisted Note Entries', async () => { - const { newSession, note, title } = await createSessionFixture(primaryTitle, primaryNote) - assert.equal(await title.getValue(), primaryTitle) - assert.match(await note.getText(), /Primary note persisted/) - - await newSession.click() - await replaceValue(await $('[aria-label="Session title"]'), secondaryTitle) - await replaceValue(await $('[aria-label="Note body"]'), 'Disposable secondary note.') - await waitForNoteAutosave() - - const primary = await sessionOption(primaryTitle) - await primary.waitForClickable() - await primary.click() - const reopenedTitle = await $('[aria-label="Session title"]') - await reopenedTitle.waitUntil(async () => (await reopenedTitle.getValue()) === primaryTitle) - assert.match(await (await $('[aria-label="Note body"]')).getText(), /Primary note persisted/) - - const secondary = await sessionOption(secondaryTitle) - await secondary.click() - await (await $('[aria-label="Delete Session"]')).click() - const confirmDelete = await button('Delete Session permanently') - await confirmDelete.waitForClickable() - await confirmDelete.click() - await secondary.waitForExist({ reverse: true }) - - await primary.click() - assert.equal(await (await $('[aria-label="Session title"]')).getValue(), primaryTitle) - assert.match(await (await $('[aria-label="Note body"]')).getText(), /Primary note persisted/) +async function waitForFixtureFile(name) { + assert.ok(fixtureDirectory, 'QA_SCRIBE_E2E_FIXTURE_DIR must be set for fixture coordination') + const path = join(fixtureDirectory, name) + await browser.waitUntil(() => existsSync(path), { + timeout: 10_000, + timeoutMsg: `Codex fixture did not create ${name}`, }) + return path +} - it('creates and deletes manual testware through the native persistence boundary', async () => { - await createSessionFixture('E2E manual testware session', 'Manual testware source note.') - await (await sessionTab('Testware')).click() - const create = await button('New Testware') - await create.waitForClickable() - await create.click() - - const record = await $('h2=Untitled testware') - await record.waitForDisplayed() - await (await $('[aria-label="Delete Untitled testware"]')).click() - const confirmDelete = await button('Delete testware permanently') - await confirmDelete.waitForClickable() - await confirmDelete.click() - await record.waitForExist({ reverse: true }) - }) +async function releaseFixtureInvocation(index) { + await waitForFixtureFile(`codex-exec-${index}.started`) + writeFileSync(join(fixtureDirectory, `codex-exec-${index}.release`), 'released by critical workflow\n', { flag: 'wx' }) +} - it('copies a Note through the native clipboard command', async () => { - await createSessionFixture('E2E clipboard session', 'Clipboard boundary source note.') - await (await sessionTab('Note')).click() - const copy = await $('[aria-label="Copy note for Jira"]') - await copy.waitForClickable() - await copy.click() - await $('[aria-label="Note copied for Jira"]').waitForDisplayed() - }) +const workflows = { + 'session-lifecycle': { + title: 'creates, edits, switches, reopens, and deletes Sessions with persisted Note Entries', + run: async () => { + const { newSession, note, title } = await createSessionFixture(primaryTitle, primaryNote) + assert.equal(await title.getValue(), primaryTitle) + assert.match(await note.getText(), /Primary note persisted/) + + await newSession.click() + await replaceValue(await $('[aria-label="Session title"]'), secondaryTitle) + await replaceValue(await $('[aria-label="Note body"]'), 'Disposable secondary note.') + await waitForNoteAutosave() + + const primary = await sessionOption(primaryTitle) + await primary.waitForClickable() + await primary.click() + const reopenedTitle = await $('[aria-label="Session title"]') + await reopenedTitle.waitUntil(async () => (await reopenedTitle.getValue()) === primaryTitle) + assert.match(await (await $('[aria-label="Note body"]')).getText(), /Primary note persisted/) + + const secondary = await sessionOption(secondaryTitle) + await secondary.click() + await (await $('[aria-label="Delete Session"]')).click() + const confirmDelete = await button('Delete Session permanently') + await confirmDelete.waitForClickable() + await confirmDelete.click() + await secondary.waitForExist({ reverse: true }) + + await primary.click() + assert.equal(await (await $('[aria-label="Session title"]')).getValue(), primaryTitle) + assert.match(await (await $('[aria-label="Note body"]')).getText(), /Primary note persisted/) + }, + }, + 'manual-testware': { + title: 'creates and deletes manual testware through the native persistence boundary', + run: async () => { + await createSessionFixture('E2E manual testware session', 'Manual testware source note.') + await (await sessionTab('Testware')).click() + const create = await button('New Testware') + await create.waitForClickable() + await create.click() + + const record = await $('h2=Untitled testware') + await record.waitForDisplayed() + await (await $('[aria-label="Delete Untitled testware"]')).click() + const confirmDelete = await button('Delete testware permanently') + await confirmDelete.waitForClickable() + await confirmDelete.click() + await record.waitForExist({ reverse: true }) + }, + }, + clipboard: { + title: 'copies a Note through the native clipboard command', + run: async () => { + await createSessionFixture('E2E clipboard session', 'Clipboard boundary source note.') + await (await sessionTab('Note')).click() + const copy = await $('[aria-label="Copy note for Jira"]') + await copy.waitForClickable() + await copy.click() + await $('[aria-label="Note copied for Jira"]').waitForDisplayed() + }, + }, + 'generation-cancellation': { + title: 'streams generation to completion and cancels a second deterministic provider job', + run: async () => { + await createSessionFixture('E2E generation session', 'Generate deterministic test cases from this note.') + const generate = await button('Generate test cases') + await generate.waitForClickable() + await generate.click() + const confirm = await (await $('dialog')).$('button=Generate test cases') + await confirm.waitForClickable() + await confirm.click() + await releaseFixtureInvocation(1) + + const generated = await $('h2=E2E test cases') + await generated.waitForDisplayed({ timeout: 15_000 }) + await browser.waitUntil( + async () => /Deterministic generated case/.test(await (await $('body')).getText()), + { timeout: 15_000, timeoutMsg: 'generated testware never reached its completed state' }, + ) + await (await $('p=Testware generated')).waitForDisplayed({ timeout: 15_000 }) + + await (await sessionTab('Note')).click() + const secondGenerate = await button('Generate test cases') + await secondGenerate.waitForClickable() + await secondGenerate.click() + const secondConfirm = await (await $('dialog')).$('button=Generate test cases') + await secondConfirm.waitForClickable() + await secondConfirm.click() + + const pending = await $('[aria-label="Pending testware title"]') + await pending.waitForDisplayed() + await waitForFixtureFile('codex-exec-2.started') + const cancel = await button('Cancel') + await cancel.waitForClickable() + await cancel.click() + await pending.waitForExist({ reverse: true, timeout: 10_000 }) + assert.match(await browser.$('body').getText(), /Generation cancelled/) + }, + }, + 'summary-recovery': { + title: 'reconciles a Summary that completes after the WebView reloads', + run: async () => { + await createSessionFixture('E2E Summary recovery session', 'Summarize this Note after a WebView reload.') + const summarize = await button('Summarize notes') + await summarize.waitForClickable() + await summarize.click() + const confirm = await (await $('dialog')).$('button=Summarize note') + await confirm.waitForClickable() + await confirm.click() + await waitForFixtureFile('codex-exec-1.started') + + await browser.refresh() + const recoveredPending = await button('Summarizing notes') + await recoveredPending.waitForDisplayed({ timeout: 10_000 }) + await releaseFixtureInvocation(1) + + const note = await $('[aria-label="Note body"]') + await browser.waitUntil( + async () => /E2E generated summary/.test(await note.getText()), + { timeout: 15_000, timeoutMsg: 'recovered Summary never replaced the stale Note' }, + ) + }, + }, +} + +const selectedWorkflow = workflows[scenario] +assert.ok(selectedWorkflow, `Unknown or missing QA_SCRIBE_E2E_SCENARIO: ${scenario ?? '(missing)'}`) - it('streams generation to completion and cancels a second deterministic provider job', async () => { - await createSessionFixture('E2E generation session', 'Generate deterministic test cases from this note.') - const generate = await button('Generate test cases') - await generate.waitForClickable() - await generate.click() - const confirm = await (await $('dialog')).$('button=Generate test cases') - await confirm.waitForClickable() - await confirm.click() - - const generated = await $('h2=E2E test cases') - await generated.waitForDisplayed({ timeout: 15_000 }) - await browser.waitUntil( - async () => /Deterministic generated case/.test(await (await $('body')).getText()), - { timeout: 15_000, timeoutMsg: 'generated testware never reached its completed state' }, +describe(`QA Scribe built application: ${scenario}`, () => { + it(selectedWorkflow.title, async () => { + assert.notEqual( + process.env.QA_SCRIBE_E2E_FORCE_FAILURE, + scenario, + `Forced E2E failure for isolation verification: ${scenario}`, ) - await (await $('p=Testware generated')).waitForDisplayed({ timeout: 15_000 }) - - await (await sessionTab('Note')).click() - const secondGenerate = await button('Generate test cases') - await secondGenerate.waitForClickable() - await secondGenerate.click() - const secondConfirm = await (await $('dialog')).$('button=Generate test cases') - await secondConfirm.waitForClickable() - await secondConfirm.click() - - const pending = await $('[aria-label="Pending testware title"]') - await pending.waitForDisplayed() - const cancel = await button('Cancel') - await cancel.waitForClickable() - await cancel.click() - await pending.waitForExist({ reverse: true, timeout: 10_000 }) - assert.match(await browser.$('body').getText(), /Generation cancelled/) + await selectedWorkflow.run() }) }) diff --git a/e2e/tauri.e2e.conf.json b/e2e/tauri.e2e.conf.json index fd46a0d..28ea2ae 100644 --- a/e2e/tauri.e2e.conf.json +++ b/e2e/tauri.e2e.conf.json @@ -3,7 +3,7 @@ "identifier": "io.github.ddv1982.qa-scribe.e2e", "build": { "beforeBuildCommand": "", - "frontendDist": "../frontend/dist" + "frontendDist": "../target/e2e/frontend-dist" }, "app": { "withGlobalTauri": true, diff --git a/e2e/wdio.conf.mjs b/e2e/wdio.conf.mjs index d6d66bd..5f252b2 100644 --- a/e2e/wdio.conf.mjs +++ b/e2e/wdio.conf.mjs @@ -4,7 +4,12 @@ import { fileURLToPath } from 'node:url' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const artifacts = process.env.QA_SCRIBE_E2E_ARTIFACTS ?? path.join(root, 'artifacts', 'e2e') const binary = process.env.QA_SCRIBE_E2E_BINARY ?? path.join(root, 'target', 'debug', process.platform === 'win32' ? 'qa-scribe-tauri.exe' : 'qa-scribe-tauri') -const spec = process.env.QA_SCRIBE_E2E_SPEC ?? '*.e2e.mjs' +const spec = process.env.QA_SCRIBE_E2E_SPEC ?? 'critical-workflows.e2e.mjs' +const criticalScenarios = new Set(['session-lifecycle', 'manual-testware', 'clipboard', 'generation-cancellation', 'summary-recovery']) + +if (spec === 'critical-workflows.e2e.mjs' && !criticalScenarios.has(process.env.QA_SCRIBE_E2E_SCENARIO)) { + throw new Error('Run critical-workflows.e2e.mjs through scripts/run-e2e.mjs or set a valid QA_SCRIBE_E2E_SCENARIO') +} export const config = { runner: 'local', diff --git a/frontend/bun.lock b/frontend/bun.lock index ccb6526..fbe3bd4 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -36,6 +36,7 @@ "@wdio/spec-reporter": "9.29.1", "@wdio/tauri-plugin": "1.2.0", "@wdio/tauri-service": "1.2.0", + "brace-expansion": "5.0.7", "eslint": "^10.5.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", @@ -604,7 +605,7 @@ "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], - "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + "brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], diff --git a/frontend/package.json b/frontend/package.json index f5564fd..dcb09ce 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "qa-scribe-frontend", "private": true, - "version": "0.7.20", + "version": "0.7.21", "type": "module", "scripts": { "dev": "vite", @@ -46,6 +46,7 @@ "@wdio/spec-reporter": "9.29.1", "@wdio/tauri-plugin": "1.2.0", "@wdio/tauri-service": "1.2.0", + "brace-expansion": "5.0.7", "eslint": "^10.5.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 5b28c18..0d3e962 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -19,7 +19,7 @@ const tauriMock = vi.hoisted(() => ({ deleteSession: vi.fn(), getProviderStatus: vi.fn(), getSettings: vi.fn(), - importClipboardScreenshot: vi.fn(), + importClipboardScreenshot: vi.fn(), deleteAttachment: vi.fn(), listDraftLibrary: vi.fn(), listDrafts: vi.fn(), listEntries: vi.fn(), diff --git a/frontend/src/app/AppOverlays.tsx b/frontend/src/app/AppOverlays.tsx index 5fa2dc3..c1eeaca 100644 --- a/frontend/src/app/AppOverlays.tsx +++ b/frontend/src/app/AppOverlays.tsx @@ -4,28 +4,34 @@ import { useModalDialog } from '../hooks/useModalDialog' import type { AppCommand } from './commandRegistry' export function PendingChangesDialog({ + recoveredSummaryConflict = false, isBusy, onCancel, onDiscard, onSave, }: { + recoveredSummaryConflict?: boolean isBusy: boolean onCancel: () => void onDiscard: () => void onSave: () => void }) { const dialogRef = useModalDialog(onCancel) + const title = recoveredSummaryConflict ? 'Choose which Note to keep' : 'Save before leaving?' + const body = recoveredSummaryConflict + ? 'A recovered Summary replaced locally authored text. Restore the authored text, keep the visible generated Summary and its edits, or stay here. Keeping the Summary also discards any other unsaved changes.' + : 'Explicit edits to Settings, Testware, or Findings have not been saved. Save them, discard them, or stay here.' return (
-

Unsaved changes

-

Save before leaving?

-

Explicit edits to Settings, Testware, or Findings have not been saved. Save them, discard them, or stay here.

+

{recoveredSummaryConflict ? 'Recovered Summary conflict' : 'Unsaved changes'}

+

{title}

+

{body}

- - + +
) diff --git a/frontend/src/app/AppShell.tsx b/frontend/src/app/AppShell.tsx index c27ec5c..3f4a60e 100644 --- a/frontend/src/app/AppShell.tsx +++ b/frontend/src/app/AppShell.tsx @@ -194,6 +194,8 @@ export function AppShell(c: AppController) { noteBody={c.noteBody} noteIsReady={c.noteIsReady} sessionTitle={c.sessionTitle} + sessionTitleValidationError={c.sessionTitleValidationError} + sessionSaveState={c.sessionSaveState} noteScreenshotCount={c.noteScreenshotCount} noteWordCount={c.noteWordCount} notice={c.notice} @@ -210,17 +212,14 @@ export function AppShell(c: AppController) { onCopyNoteScreenshot={c.handleCopyNoteScreenshotForJira} onDeleteSession={c.requestDeleteSession} onOpenSession={c.openSession} - onSetNoteBody={(value) => { - c.setLatestNoteGenerationUndo(null) - c.setNoteBody(value) - }} + onSetNoteBody={c.setNoteBody} onSetSessionTitle={c.setSessionTitle} onUploadImage={(input) => { if (!c.noteEntry) { c.setError('Open a Session with an editable Note Entry before uploading images.') return } - return c.uploadEditorImage(input, c.noteEntry.id) + return c.uploadEditorImage(input, { kind: 'note', id: c.noteEntry.id }) }} /> ) : null} @@ -250,7 +249,7 @@ export function AppShell(c: AppController) { onPrefillFromNote={c.handlePrefillTestwareFromNote} onSaveDraft={c.handleSaveDraft} onDiscardDraft={c.discardLocalDraft} - onUploadImage={(input) => c.uploadEditorImage(input, null)} + onUploadImage={(input, recordId) => c.uploadEditorImage(input, { kind: 'draft', id: recordId })} updateLocalDraft={c.updateLocalDraft} /> ) : null} @@ -280,7 +279,7 @@ export function AppShell(c: AppController) { onPrefillFromNote={c.handlePrefillFindingFromNote} onSaveFinding={c.handleSaveFinding} onDiscardFinding={c.discardLocalFinding} - onUploadImage={(input) => c.uploadEditorImage(input, null)} + onUploadImage={(input, recordId) => c.uploadEditorImage(input, { kind: 'finding', id: recordId })} updateLocalFinding={c.updateLocalFinding} /> ) : null} @@ -339,9 +338,10 @@ export function AppShell(c: AppController) { {c.pendingNavigationView ? ( void c.discardPendingNavigationChanges()} onSave={() => void c.savePendingNavigationChanges()} /> ) : null} diff --git a/frontend/src/app/attachmentActions.test.ts b/frontend/src/app/attachmentActions.test.ts index c954b5c..2bb9171 100644 --- a/frontend/src/app/attachmentActions.test.ts +++ b/frontend/src/app/attachmentActions.test.ts @@ -4,8 +4,10 @@ import type { ClipboardEvent } from 'react' import type { Editor } from '@tiptap/react' import { entryFixture, sessionFixture } from '../test/fixtures' import { registerRichEditor } from '../editor/richEditorRegistry' +import type { BusyAction } from '../ui/types' const tauriMock = vi.hoisted(() => ({ + deleteAttachment: vi.fn(), importClipboardScreenshot: vi.fn(), readClipboardImageDataUrl: vi.fn(), EDITOR_HTML_TAGS: ['a', 'b', 'br', 'em', 'h2', 'h3', 'i', 'img', 'input', 'li', 'ol', 'p', 'strong', 'ul'], @@ -21,6 +23,7 @@ describe('attachment paste actions', () => { beforeEach(() => { vi.clearAllMocks() tauriMock.importClipboardScreenshot.mockResolvedValue({ id: 'attachment-1', filename: 'clip.png' }) + tauriMock.deleteAttachment.mockResolvedValue(true) tauriMock.readClipboardImageDataUrl.mockResolvedValue('data:image/png;base64,BBBB') }) @@ -107,6 +110,95 @@ describe('attachment paste actions', () => { unregister() }) + + it('removes a pasted attachment when its editor unmounts before import completes', async () => { + const pendingImport = deferred<{ id: string; filename: string }>() + tauriMock.importClipboardScreenshot.mockReturnValueOnce(pendingImport.promise) + const ctx = workflowContext() + const { target, insertImage, unregister } = mountEditor() + const paste = pasteEvent( + target, + dataTransfer({ files: [new File(['image'], 'late.png', { type: 'image/png' })] }), + ) + + createAttachmentActions(ctx).handlePaste(paste.event) + await waitFor(() => expect(tauriMock.importClipboardScreenshot).toHaveBeenCalledTimes(1)) + unregister() + pendingImport.resolve({ id: 'attachment-stale-paste', filename: 'late.png' }) + + await waitFor(() => expect(tauriMock.deleteAttachment).toHaveBeenCalledWith('attachment-stale-paste')) + expect(insertImage).not.toHaveBeenCalled() + }) + + it('removes an uploaded attachment when its editor unmounts before import completes', async () => { + const pendingImport = deferred<{ id: string; filename: string }>() + tauriMock.importClipboardScreenshot.mockReturnValueOnce(pendingImport.promise) + const { editorId, insertImage, unregister } = mountEditor() + const actions = createAttachmentActions(workflowContext()) + const upload = actions.uploadEditorImage({ + editorId, + file: new File(['image'], 'late.png', { type: 'image/png' }), + insertImage, + }, { kind: 'draft', id: 'draft-1' }) + await waitFor(() => expect(tauriMock.importClipboardScreenshot).toHaveBeenCalledTimes(1)) + unregister() + pendingImport.resolve({ id: 'attachment-stale-upload', filename: 'late.png' }) + await upload + + expect(tauriMock.deleteAttachment).toHaveBeenCalledWith('attachment-stale-upload') + expect(insertImage).not.toHaveBeenCalled() + }) + + it('keeps attachment busy state until every concurrent upload finishes', async () => { + const firstImport = deferred<{ id: string; filename: string }>() + const secondImport = deferred<{ id: string; filename: string }>() + tauriMock.importClipboardScreenshot + .mockReturnValueOnce(firstImport.promise) + .mockReturnValueOnce(secondImport.promise) + const { editorId, insertImage, unregister } = mountEditor() + const ctx = workflowContext() + let busy: BusyAction | null = null + ctx.feedback.setBusyAction = (next) => { + busy = typeof next === 'function' ? next(busy) : next + } + const actions = createAttachmentActions(ctx) + const firstUpload = actions.uploadEditorImage({ + editorId, + file: new File(['first'], 'first.png', { type: 'image/png' }), + insertImage, + }, { kind: 'draft', id: 'draft-1' }) + const secondUpload = actions.uploadEditorImage({ + editorId, + file: new File(['second'], 'second.png', { type: 'image/png' }), + insertImage, + }, { kind: 'draft', id: 'draft-1' }) + await waitFor(() => expect(tauriMock.importClipboardScreenshot).toHaveBeenCalledTimes(2)) + expect(busy).toBe('attach-image') + + firstImport.resolve({ id: 'attachment-first', filename: 'first.png' }) + await firstUpload + expect(busy).toBe('attach-image') + + secondImport.resolve({ id: 'attachment-second', filename: 'second.png' }) + await secondUpload + expect(busy).toBeNull() + unregister() + }) + + it('removes an imported attachment when the editor declines insertion', async () => { + const { editorId, insertImage, unregister } = mountEditor() + insertImage.mockReturnValueOnce(false) + const actions = createAttachmentActions(workflowContext()) + + await actions.uploadEditorImage({ + editorId, + file: new File(['image'], 'declined.png', { type: 'image/png' }), + insertImage, + }, { kind: 'draft', id: 'draft-1' }) + + expect(tauriMock.deleteAttachment).toHaveBeenCalledWith('attachment-1') + unregister() + }) }) function workflowContext(): AttachmentActionsContext { @@ -114,13 +206,13 @@ function workflowContext(): AttachmentActionsContext { session: { activeSession: sessionFixture(), noteEntry: entryFixture(), - setNoteBody: vi.fn(), }, feedback: { setBusyAction: vi.fn(), setError: vi.fn(), setNotice: vi.fn(), }, + registerImportedAttachment: vi.fn(), } } @@ -131,13 +223,19 @@ function mountEditor() { const target = document.createElement('span') editor.append(target) document.body.append(editor) - const insertImage = vi.fn() + const insertImage = vi.fn(() => true) const unregister = registerRichEditor(editor.id, { editor: {} as Editor, insertImage, readOnly: false, }) - return { insertImage, target, unregister } + return { editorId: editor.id, insertImage, target, unregister } +} + +function deferred() { + let resolve: (value: T) => void = () => {} + const promise = new Promise((nextResolve) => { resolve = nextResolve }) + return { promise, resolve } } function pasteEvent(target: HTMLElement, clipboardData: DataTransfer) { diff --git a/frontend/src/app/attachmentActions.ts b/frontend/src/app/attachmentActions.ts index 2a3f5af..2c24798 100644 --- a/frontend/src/app/attachmentActions.ts +++ b/frontend/src/app/attachmentActions.ts @@ -1,5 +1,5 @@ import type { ClipboardEvent } from 'react' -import { importClipboardScreenshot, readClipboardImageDataUrl } from '../tauri' +import { deleteAttachment, importClipboardScreenshot, readClipboardImageDataUrl } from '../tauri' import { containsInlineImageData, inlineImageFilename, @@ -13,7 +13,7 @@ import { type RichEditorDocument, } from '../editor/editorDocument' import type { RichEditorImageUpload } from '../editor/RichTextEditor' -import { richEditorImageInserterForElement, type RichEditorImageInserter } from '../editor/richEditorRegistry' +import { richEditorImageInserterForElement, richEditorImageInserterForId, type RichEditorImageInserter } from '../editor/richEditorRegistry' import { formatError } from '../ui/format' import type { SessionWorkspace, WorkflowFeedback } from './types' import { useStableCapability } from './useStableCapability' @@ -41,11 +41,95 @@ export function shouldReadNativeClipboardImage(clipboardData: DataTransfer): boo } export type AttachmentActionsContext = { - session: Pick + session: Pick feedback: WorkflowFeedback + registerImportedAttachment: (owner: AttachmentUploadOwner, attachmentId: string) => void +} + +export type AttachmentUploadOwner = { kind: 'note' | 'draft' | 'finding'; id: string } + +export type InlineImageMaterialization = { + document: RichEditorDocument + importedAttachmentIds: string[] } export function createAttachmentActions(ctx: AttachmentActionsContext) { + const pendingMutations = new Set>() + const pendingCleanupIds = new Set() + let activeAttachmentActions = 0 + + function trackMutation(operation: Promise): Promise { + const tracked = operation.finally(() => pendingMutations.delete(tracked)) + pendingMutations.add(tracked) + return tracked + } + + function runAttachmentAction(action: () => Promise): Promise { + activeAttachmentActions += 1 + ctx.feedback.setBusyAction('attach-image') + const operation = action().finally(() => { + activeAttachmentActions -= 1 + if (activeAttachmentActions === 0) { + ctx.feedback.setBusyAction((current) => current === 'attach-image' ? null : current) + } + }) + return trackMutation(operation) + } + + async function waitForPendingAttachmentMutations(): Promise { + while (pendingMutations.size > 0) { + await Promise.allSettled(Array.from(pendingMutations)) + } + } + + async function cleanupMaterializedAttachments(attachmentIds: string[]): Promise { + for (const id of attachmentIds) pendingCleanupIds.add(id) + let cleaned = true + for (const id of Array.from(pendingCleanupIds)) { + try { + const deleted = await trackMutation(deleteAttachment(id)) + if (deleted) { + pendingCleanupIds.delete(id) + } else { + cleaned = false + ctx.feedback.setError('An imported image is still referenced. Save the latest content before closing.') + } + } catch (cause) { + cleaned = false + ctx.feedback.setError(`Could not discard an imported image. ${formatError(cause)}`) + } + } + return cleaned + } + + function hasPendingAttachmentMutations(): boolean { + return pendingMutations.size > 0 || pendingCleanupIds.size > 0 + } + + function hasPendingAttachmentOperations(): boolean { + return pendingMutations.size > 0 + } + + function retryPendingAttachmentCleanup(): Promise { + return cleanupMaterializedAttachments([]) + } + + async function discardImportedAttachment(attachmentId: string): Promise { + if (!await cleanupMaterializedAttachments([attachmentId])) { + throw new Error('The stale imported image could not be discarded.') + } + } + + function notePasteIsCurrent( + sessionId: string, + entryId: string, + editor: HTMLElement, + insertImage: RichEditorImageInserter, + ): boolean { + return ctx.session.activeSession?.id === sessionId + && ctx.session.noteEntry?.id === entryId + && richEditorImageInserterForElement(editor) === insertImage + } function handlePaste(event: ClipboardEvent) { const target = event.target as HTMLElement | null const editor = target?.closest('.rich-editor') @@ -57,114 +141,163 @@ export function createAttachmentActions(ctx: AttachmentActionsContext) { const file = imageFileFromClipboardData(event.clipboardData) if (file) { event.preventDefault() - void importPastedImage(file, insertImage) + void runAttachmentAction(() => importPastedImage(file, insertImage, editor)) return } if (shouldReadNativeClipboardImage(event.clipboardData)) { event.preventDefault() - void importNativeClipboardImage(insertImage) + void runAttachmentAction(() => importNativeClipboardImage(insertImage, editor)) } } - async function importPastedImage(file: File, insertImage: RichEditorImageInserter) { + async function importPastedImage(file: File, insertImage: RichEditorImageInserter, editor: HTMLElement) { if (!ctx.session.activeSession || !ctx.session.noteEntry) { ctx.feedback.setError('Open a Session before pasting images.') return } + const sessionId = ctx.session.activeSession.id + const entryId = ctx.session.noteEntry.id try { - ctx.feedback.setBusyAction('attach-image') ctx.feedback.setError(null) const dataUrl = await readFileAsDataUrl(file) + if (!notePasteIsCurrent(sessionId, entryId, editor, insertImage)) return const filename = pastedImageFilename(file) - const attachment = await importClipboardScreenshot({ - sessionId: ctx.session.activeSession.id, - entryId: ctx.session.noteEntry.id, + const attachment = await trackMutation(importClipboardScreenshot({ + sessionId, + entryId, filename, dataUrl, - }) - insertImage(attachment.id, attachment.filename, dataUrl) + })) + if (!notePasteIsCurrent(sessionId, entryId, editor, insertImage)) { + await discardImportedAttachment(attachment.id) + return + } + try { + if (!insertImage(attachment.id, attachment.filename, dataUrl)) { + await discardImportedAttachment(attachment.id) + return + } + ctx.registerImportedAttachment({ kind: 'note', id: entryId }, attachment.id) + } catch (cause) { + await discardImportedAttachment(attachment.id) + throw cause + } ctx.feedback.setNotice('Image attached') } catch (cause) { ctx.feedback.setError(formatError(cause)) - } finally { - ctx.feedback.setBusyAction(null) } } - async function importNativeClipboardImage(insertImage: RichEditorImageInserter) { + async function importNativeClipboardImage(insertImage: RichEditorImageInserter, editor: HTMLElement) { if (!ctx.session.activeSession || !ctx.session.noteEntry) { ctx.feedback.setError('Open a Session before pasting images.') return } + const sessionId = ctx.session.activeSession.id + const entryId = ctx.session.noteEntry.id try { - ctx.feedback.setBusyAction('attach-image') ctx.feedback.setError(null) const dataUrl = await readClipboardImageDataUrl() if (!dataUrl) { ctx.feedback.setError('Clipboard image could not be read.') return } - const attachment = await importClipboardScreenshot({ - sessionId: ctx.session.activeSession.id, - entryId: ctx.session.noteEntry.id, + if (!notePasteIsCurrent(sessionId, entryId, editor, insertImage)) return + const attachment = await trackMutation(importClipboardScreenshot({ + sessionId, + entryId, filename: `pasted-image-${Date.now()}.png`, dataUrl, - }) - insertImage(attachment.id, attachment.filename, dataUrl) + })) + if (!notePasteIsCurrent(sessionId, entryId, editor, insertImage)) { + await discardImportedAttachment(attachment.id) + return + } + try { + if (!insertImage(attachment.id, attachment.filename, dataUrl)) { + await discardImportedAttachment(attachment.id) + return + } + ctx.registerImportedAttachment({ kind: 'note', id: entryId }, attachment.id) + } catch (cause) { + await discardImportedAttachment(attachment.id) + throw cause + } ctx.feedback.setNotice('Image attached') } catch (cause) { ctx.feedback.setError(formatError(cause)) - } finally { - ctx.feedback.setBusyAction(null) } } - async function uploadEditorImage({ file, insertImage }: RichEditorImageUpload, entryId: string | null) { + function uploadEditorImage(input: RichEditorImageUpload, owner: AttachmentUploadOwner): Promise { + return runAttachmentAction(() => uploadEditorImageNow(input, owner)) + } + + async function uploadEditorImageNow({ editorId, file, insertImage }: RichEditorImageUpload, owner: AttachmentUploadOwner) { if (!ctx.session.activeSession) { ctx.feedback.setError('Open a Session before uploading images.') return } - if (entryId && !ctx.session.noteEntry) { + if (owner.kind === 'note' && !ctx.session.noteEntry) { ctx.feedback.setError('Open a Session with an editable Note Entry before uploading images.') return } + const sessionId = ctx.session.activeSession.id + const expectedEntryId = owner.kind === 'note' ? owner.id : null + const isCurrent = () => ( + ctx.session.activeSession?.id === sessionId + && (expectedEntryId === null || ctx.session.noteEntry?.id === expectedEntryId) + && richEditorImageInserterForId(editorId) === insertImage + ) try { - ctx.feedback.setBusyAction('attach-image') ctx.feedback.setError(null) const dataUrl = await readFileAsDataUrl(file) + if (!isCurrent()) return const filename = pastedImageFilename(file) - const attachment = await importClipboardScreenshot({ - sessionId: ctx.session.activeSession.id, - entryId, + const attachment = await trackMutation(importClipboardScreenshot({ + sessionId, + entryId: expectedEntryId, filename, dataUrl, - }) - insertImage(attachment.id, attachment.filename, dataUrl) + })) + if (!isCurrent()) { + await discardImportedAttachment(attachment.id) + return + } + try { + if (!insertImage(attachment.id, attachment.filename, dataUrl)) { + await discardImportedAttachment(attachment.id) + return + } + ctx.registerImportedAttachment(owner, attachment.id) + } catch (cause) { + await discardImportedAttachment(attachment.id) + throw cause + } ctx.feedback.setNotice('Image attached') } catch (cause) { ctx.feedback.setError(formatError(cause)) - } finally { - ctx.feedback.setBusyAction(null) } } async function materializeInlineImages( document: RichEditorDocument, - options: { entryId?: string | null; updateNoteBody?: boolean } = {}, - ): Promise { + options: { entryId?: string | null; isCurrent?: () => boolean } = {}, + ): Promise { const html = richEditorDocumentToHtml(document) if (!containsInlineImageData(html)) { - return document + return { document, importedAttachmentIds: [] } } if (!ctx.session.activeSession) { throw new Error('Open a Session before storing embedded images.') } + const sessionId = ctx.session.activeSession.id const entryId = Object.prototype.hasOwnProperty.call(options, 'entryId') ? options.entryId : ctx.session.noteEntry?.id if (entryId === undefined) { @@ -175,31 +308,50 @@ export function createAttachmentActions(ctx: AttachmentActionsContext) { const images = Array.from(documentFragment.body.querySelectorAll('img')).filter((image) => (image.getAttribute('src') ?? '').startsWith('data:image/'), ) + const importedAttachmentIds: string[] = [] - for (let index = 0; index < images.length; index += 1) { - const image = images[index] - const dataUrl = image.getAttribute('src') - if (!dataUrl) continue - - const filename = inlineImageFilename(image, index, dataUrl) - const attachment = await importClipboardScreenshot({ - sessionId: ctx.session.activeSession.id, - entryId, - filename, - dataUrl, - }) - image.setAttribute('data-attachment-id', attachment.id) - image.setAttribute('src', `${managedAttachmentProtocol}${attachment.id}`) - image.setAttribute('alt', image.getAttribute('alt') || attachment.filename) - image.removeAttribute('srcset') + try { + for (let index = 0; index < images.length; index += 1) { + const image = images[index] + const dataUrl = image.getAttribute('src') + if (!dataUrl) continue + + const filename = inlineImageFilename(image, index, dataUrl) + const attachment = await trackMutation(importClipboardScreenshot({ + sessionId, + entryId, + filename, + dataUrl, + })) + importedAttachmentIds.push(attachment.id) + if (options.isCurrent && !options.isCurrent()) { + await cleanupMaterializedAttachments(importedAttachmentIds) + return { document, importedAttachmentIds: [] } + } + image.setAttribute('data-attachment-id', attachment.id) + image.setAttribute('src', `${managedAttachmentProtocol}${attachment.id}`) + image.setAttribute('alt', image.getAttribute('alt') || attachment.filename) + image.removeAttribute('srcset') + } + } catch (cause) { + await cleanupMaterializedAttachments(importedAttachmentIds) + throw cause } const body = richEditorDocumentFromHtml(documentFragment.body.innerHTML) - if (options.updateNoteBody) ctx.session.setNoteBody(body) - return body + return { document: body, importedAttachmentIds } } - return { handlePaste, materializeInlineImages, uploadEditorImage } + return { + cleanupMaterializedAttachments, + handlePaste, + hasPendingAttachmentOperations, + hasPendingAttachmentMutations, + materializeInlineImages, + retryPendingAttachmentCleanup, + uploadEditorImage, + waitForPendingAttachmentMutations, + } } export function useAttachmentActions(ctx: AttachmentActionsContext) { diff --git a/frontend/src/app/deleteConfirmationDialog.test.tsx b/frontend/src/app/deleteConfirmationDialog.test.tsx index 0aa1d3f..acc7539 100644 --- a/frontend/src/app/deleteConfirmationDialog.test.tsx +++ b/frontend/src/app/deleteConfirmationDialog.test.tsx @@ -1,6 +1,7 @@ import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { DeleteConfirmationDialog } from './AppShell' +import { PendingChangesDialog } from './AppOverlays' import { simulateDialogCancel } from '../test/dialogPolyfill' const copy = { title: 'Delete this note?', body: 'This cannot be undone.', confirmLabel: 'Delete note' } @@ -41,3 +42,27 @@ describe('DeleteConfirmationDialog accessibility', () => { trigger.remove() }) }) + +describe('PendingChangesDialog recovery conflict', () => { + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('explains the recovered Summary outcomes with explicit actions', () => { + render( + , + ) + + expect(screen.getByRole('heading', { name: 'Choose which Note to keep' })).toBeTruthy() + expect(screen.getByText(/recovered Summary replaced locally authored text/i)).toBeTruthy() + expect(screen.getByRole('button', { name: 'Keep generated Summary' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Restore authored text' })).toBeTruthy() + }) +}) diff --git a/frontend/src/app/generationActions.ts b/frontend/src/app/generationActions.ts index 0ec8adc..d9207a8 100644 --- a/frontend/src/app/generationActions.ts +++ b/frontend/src/app/generationActions.ts @@ -2,10 +2,8 @@ import { cancelAiActionJob, getAiActionJobStatus, listActiveAiActionJobs, + openSessionNoteState, startAiActionJob, - updateDraft, - updateEntry, - updateFinding, type Draft, type Entry, type Finding, @@ -21,9 +19,11 @@ import { serializeRichEditorDocument, } from '../editor/editorDocument' import { formatError } from '../ui/format' -import type { AiSelection, GenerationWorkspace, RecordWorkspace, SessionWorkspace, WorkflowFeedback, WorkflowNavigation } from './types' +import type { GenerationActionsContext } from './generationActions.types' import { useStableCapability } from './useStableCapability' +export type { GenerationActionsContext } from './generationActions.types' + export function generationIsActive(job: GenerationJobStatus): boolean { return job.state === 'starting' || job.state === 'running' || job.state === 'cancelling' } @@ -33,78 +33,153 @@ export function generationIsActive(job: GenerationJobStatus): boolean { // re-subscribe to the streaming events; polling `get_ai_action_job_status` is // the simplest way to drive the recovered job to a terminal UI state. const RECONCILE_POLL_INTERVAL_MS = 1000 - -export type GenerationActionsContext = { - session: Pick< - SessionWorkspace, - | 'activeSession' - | 'activeSessionIdRef' - | 'noteBodyRef' - | 'noteBodyWriteVersionRef' - | 'noteEntry' - | 'noteEntryIdRef' - | 'savedBodyRef' - | 'setNoteBody' - | 'setNoteEntry' - > - records: Pick< - RecordWorkspace, - | 'dirtyDraftIdsRef' - | 'dirtyFindingIdsRef' - | 'draftsRef' - | 'findingsRef' - | 'setDrafts' - | 'setFindings' - | 'setFindingCount' - | 'setTestwareDraftCount' - > - generation: GenerationWorkspace - selection: AiSelection - feedback: WorkflowFeedback - navigation: WorkflowNavigation - saveNoteNow: (options?: { manageBusy?: boolean }) => Promise -} +const RECOVERY_COMMAND_MAX_ATTEMPTS = 3 export function createGenerationActions(ctx: GenerationActionsContext) { + let reconciliationStarted = false + const recoveredSummaryReloadVersions = new Map() + function storeGenerationStatus(status: GenerationJobStatus) { ctx.generation.setGenerationJobs((previous) => ({ ...previous, [status.jobId]: status })) } - // Backend jobs keep running when the webview reloads, but the frontend loses - // its job map and the streaming `Channel`. On boot we ask the backend which - // jobs are still active, restore their busy/pending UI state, and poll each to - // completion so the spinner/cancel affordance and the terminal notice recover. - // The generated artifact itself was already persisted by the backend worker; - // reopening the session surfaces it, so reconciliation only owns the UI state. - async function reconcileActiveJobs() { + // This capture is a distinct startup phase so recovered Summary jobs can + // block Note saves before the startup Session is hydrated. Polling starts + // only after hydration, when a completed Summary can be applied safely. + async function captureActiveJobs() { + let activeJobsCommand: typeof listActiveAiActionJobs + try { + activeJobsCommand = listActiveAiActionJobs + } catch { + // Generated Tauri bindings always provide this function. Lightweight + // non-Tauri hosts may intentionally omit recovery commands. + ctx.summaryRecovery.discoveryPendingRef.current = false + return + } + if (typeof activeJobsCommand !== 'function') { + ctx.summaryRecovery.discoveryPendingRef.current = false + return + } + ctx.summaryRecovery.discoveryPendingRef.current = true let active: GenerationJobStatus[] try { - active = await listActiveAiActionJobs() + active = await runRecoveryCommand(activeJobsCommand) } catch (cause) { - // A reconciliation failure must never block boot; surface it quietly. + // Do not hydrate a Note from an uncertain startup. A restart can retry + // after the bounded transient-error budget is exhausted. ctx.feedback.setError(formatError(cause)) - return + throw cause } + + ctx.summaryRecovery.recoveredJobsRef.current.clear() + ctx.summaryRecovery.unresolvedSummaryJobsRef.current.clear() for (const status of active) { + ctx.summaryRecovery.recoveredJobsRef.current.set(status.jobId, status) + if (status.action === 'summary') { + ctx.summaryRecovery.unresolvedSummaryJobsRef.current.set(status.jobId, status.sessionId) + } storeGenerationStatus(status) + } + ctx.summaryRecovery.discoveryPendingRef.current = false + if (reconciliationStarted) startCapturedJobPolls() + } + + function reconcileActiveJobs(): Promise { + reconciliationStarted = true + startCapturedJobPolls() + return Promise.resolve() + } + + function startCapturedJobPolls() { + const active = Array.from(ctx.summaryRecovery.recoveredJobsRef.current.values()) + ctx.summaryRecovery.recoveredJobsRef.current.clear() + for (const status of active) { void pollJobToTerminal(status.jobId) } } + function summaryRecoveryBlocksSession(sessionId: string): boolean { + return ctx.summaryRecovery.discoveryPendingRef.current + || Array.from(ctx.summaryRecovery.unresolvedSummaryJobsRef.current.values()).includes(sessionId) + } + + function flushBlockedNoteSave(sessionId: string | null) { + if ( + !sessionId + || ctx.session.activeSessionIdRef.current !== sessionId + || summaryRecoveryBlocksSession(sessionId) + || !ctx.summaryRecovery.blockedSaveSessionIdsRef.current.delete(sessionId) + ) return + void ctx.saveNoteNow({ manageBusy: false }) + } + + function finishSummaryReconciliation(jobId: string, sessionId: string, flushDirtyNote: boolean) { + ctx.summaryRecovery.unresolvedSummaryJobsRef.current.delete(jobId) + if (summaryRecoveryBlocksSession(sessionId)) return + if (flushDirtyNote) flushBlockedNoteSave(sessionId) + else ctx.summaryRecovery.blockedSaveSessionIdsRef.current.delete(sessionId) + } + + async function reloadRecoveredSummary(sessionId: string): Promise { + try { + const canonical = await runRecoveryCommand(() => openSessionNoteState(sessionId)) + if ( + canonical.session.id !== sessionId + || canonical.noteEntry.sessionId !== sessionId + ) { + ctx.feedback.setError('Recovered Summary returned an unexpected Session or Note Entry.') + return null + } + return canonical.noteEntry + } catch (cause) { + ctx.feedback.setError(formatError(cause)) + return null + } + } + async function pollJobToTerminal(jobId: string) { - // Loop until the job reports a terminal state (or vanishes from the store, - // which also counts as terminal). Errors surface once and stop the poll. + let consecutiveStatusErrors = 0 for (;;) { await delay(RECONCILE_POLL_INTERVAL_MS) let status: GenerationJobStatus try { status = await getAiActionJobStatus(jobId) - } catch { - // The job left the store (pruned/unknown); nothing more to reconcile. + } catch (cause) { + consecutiveStatusErrors += 1 + if (consecutiveStatusErrors < RECOVERY_COMMAND_MAX_ATTEMPTS) continue + // Keep Summary protection in place, but stop the bounded retry loop. + // Restarting the app starts a fresh discovery/reconciliation attempt. + ctx.feedback.setError(formatError(cause)) return } + consecutiveStatusErrors = 0 storeGenerationStatus(status) if (!generationIsActive(status)) { + const summarySessionId = ctx.summaryRecovery.unresolvedSummaryJobsRef.current.get(jobId) + if (summarySessionId && status.state === 'completed') { + const reloadVersion = (recoveredSummaryReloadVersions.get(summarySessionId) ?? 0) + 1 + recoveredSummaryReloadVersions.set(summarySessionId, reloadVersion) + const canonicalEntry = await reloadRecoveredSummary(summarySessionId) + if (!canonicalEntry) return + + if (recoveredSummaryReloadVersions.get(summarySessionId) !== reloadVersion) { + // A later reload started for this Session, so this response cannot + // be the freshest canonical Summary even if it resolved last. + } else if ( + ctx.session.activeSessionIdRef.current === summarySessionId + && ctx.session.noteEntryIdRef.current === canonicalEntry.id + ) { + applyRecoveredGeneratedNoteEntry(canonicalEntry) + ctx.summaryRecovery.completedSummaryEntriesRef.current.delete(summarySessionId) + } else if (ctx.summaryRecovery.openingSessionIdRef.current === summarySessionId) { + // An in-flight open may have captured the pre-completion Note. Let + // Session hydration consume this canonical result instead. + ctx.summaryRecovery.completedSummaryEntriesRef.current.set(summarySessionId, canonicalEntry) + } + } + if (summarySessionId) { + finishSummaryReconciliation(jobId, summarySessionId, status.state !== 'completed') + } if (status.state === 'failed' && status.errorMessage) ctx.feedback.setError(status.errorMessage) else if (status.state === 'cancelled') ctx.feedback.setNotice('Generation cancelled') else if (status.state === 'completed') ctx.feedback.setNotice('Generation finished') @@ -113,6 +188,21 @@ export function createGenerationActions(ctx: GenerationActionsContext) { } } + async function runRecoveryCommand(command: () => Promise): Promise { + let lastCause: unknown + for (let attempt = 1; attempt <= RECOVERY_COMMAND_MAX_ATTEMPTS; attempt += 1) { + try { + return await command() + } catch (cause) { + lastCause = cause + if (attempt < RECOVERY_COMMAND_MAX_ATTEMPTS) { + await delay(RECONCILE_POLL_INTERVAL_MS) + } + } + } + throw lastCause + } + function mergeDraft(draft: Draft) { ctx.records.setDrafts((previous) => { const exists = previous.some((item) => item.id === draft.id) @@ -141,48 +231,125 @@ export function createGenerationActions(ctx: GenerationActionsContext) { const previousBody = ctx.session.noteBodyRef.current const generatedBody = richEditorDocumentFromStoredBody(generatedEntry) const nextBody = preserveManagedImageNodes(previousBody, generatedBody) + const generatedSerialized = serializeRichEditorDocument(generatedBody) + const nextSerialized = serializeRichEditorDocument(nextBody) const storedBody = richEditorDocumentToStoredBody(nextBody) const richNoteEntry = { ...generatedEntry, ...storedBody } - const writeVersion = ++ctx.session.noteBodyWriteVersionRef.current - + ctx.adoptCanonicalNoteBody(generatedBody, richNoteEntry.id, richNoteEntry.sessionId) ctx.generation.setLatestNoteGenerationUndo({ entryId: richNoteEntry.id, before: previousBody }) ctx.session.setNoteEntry(richNoteEntry) ctx.session.setNoteBody(nextBody) - ctx.session.savedBodyRef.current = serializeRichEditorDocument(nextBody) - void updateEntry(richNoteEntry.id, storedBody) - .then((saved) => { - if (writeVersion !== ctx.session.noteBodyWriteVersionRef.current) return - ctx.session.setNoteEntry(saved) - ctx.session.savedBodyRef.current = serializeRichEditorDocument(richEditorDocumentFromStoredBody(saved)) - }) - .catch((cause) => { - if (writeVersion === ctx.session.noteBodyWriteVersionRef.current) ctx.feedback.setError(formatError(cause)) - }) + // The provider result is already canonical in the backend. Any preserved + // images remain visibly dirty until their compensating write succeeds. + ctx.session.savedBodyRef.current = generatedSerialized + if (generatedSerialized === nextSerialized) { + ctx.feedback.setNotice('Note summarized') + return + } + void ctx.saveNoteBody(nextBody, { + manageBusy: false, + entryId: richNoteEntry.id, + sessionId: richNoteEntry.sessionId, + expectedCurrentBody: nextSerialized, + allowRecoveryWrite: true, + }) ctx.feedback.setNotice('Note summarized') } - async function handleUndoLatestNoteGeneration() { - if (!ctx.generation.latestNoteGenerationUndo || ctx.session.noteEntry?.id !== ctx.generation.latestNoteGenerationUndo.entryId) return - const undo = ctx.generation.latestNoteGenerationUndo - const storedBody = richEditorDocumentToStoredBody(undo.before) - const writeVersion = ++ctx.session.noteBodyWriteVersionRef.current + function applyRecoveredGeneratedNoteEntry(generatedEntry: Entry) { + const previousBody = ctx.session.noteBodyRef.current + const priorRecoveryDecision = ctx.latestNoteGenerationUndoRef.current?.pendingRecoveryDecision + && ctx.latestNoteGenerationUndoRef.current.entryId === generatedEntry.id + ? ctx.latestNoteGenerationUndoRef.current + : null + const authoredBody = priorRecoveryDecision?.pendingRecoveryChoice === 'generated' + ? previousBody + : priorRecoveryDecision?.before ?? previousBody + const generatedBody = richEditorDocumentFromStoredBody(generatedEntry) + const nextBody = preserveManagedImageNodes(previousBody, generatedBody) + const previousSerialized = serializeRichEditorDocument(previousBody) + const authoredSerialized = serializeRichEditorDocument(authoredBody) + const generatedSerialized = serializeRichEditorDocument(generatedBody) + const nextSerialized = serializeRichEditorDocument(nextBody) + const storedBody = richEditorDocumentToStoredBody(nextBody) + const richNoteEntry = { ...generatedEntry, ...storedBody } + ctx.adoptCanonicalNoteBody(generatedBody, richNoteEntry.id, richNoteEntry.sessionId) + const hasDirtyAuthoredBody = ( + Boolean(priorRecoveryDecision) + || previousSerialized !== ctx.session.savedBodyRef.current + ) && authoredSerialized !== nextSerialized + + // Dirty authored text and a recovered backend completion are two valid, + // conflicting outcomes. Keep that choice explicit instead of silently + // treating the generated body as saved or reducing it to ordinary undo. + if (hasDirtyAuthoredBody) { + ctx.generation.setLatestNoteGenerationUndo({ + entryId: richNoteEntry.id, + before: authoredBody, + pendingRecoveryDecision: true, + generated: nextBody, + generatedCanonical: generatedBody, + }) + } else if (previousSerialized !== nextSerialized) { + ctx.generation.setLatestNoteGenerationUndo({ entryId: richNoteEntry.id, before: previousBody }) + } + + ctx.session.setNoteEntry(richNoteEntry) + ctx.session.setNoteBody(nextBody) + // While the recovery choice is unresolved, comparing the generated editor + // body with the authored value keeps save-state and close protection + // truthful. Discard installs the generated value as the saved baseline; + // save persists the authored value. + ctx.session.savedBodyRef.current = hasDirtyAuthoredBody ? authoredSerialized : generatedSerialized + + // The backend already persisted the generated Note. Only write again when + // frontend image preservation actually changed that canonical document. + if (hasDirtyAuthoredBody || generatedSerialized === nextSerialized) return + void ctx.saveNoteBody(nextBody, { + manageBusy: false, + entryId: richNoteEntry.id, + sessionId: richNoteEntry.sessionId, + expectedCurrentBody: nextSerialized, + allowRecoveryWrite: true, + }) + } + + async function handleUndoLatestNoteGeneration() { + if (!ctx.latestNoteGenerationUndoRef.current || ctx.session.noteEntry?.id !== ctx.latestNoteGenerationUndoRef.current.entryId) return + const undo = ctx.latestNoteGenerationUndoRef.current + if (undo.pendingRecoveryDecision) { + const authoredUndo = { ...undo, pendingRecoveryChoice: 'authored' as const } + ctx.latestNoteGenerationUndoRef.current = authoredUndo + ctx.generation.setLatestNoteGenerationUndo(authoredUndo) + const saved = await ctx.saveNoteNow() + if (saved && ctx.latestNoteGenerationUndoRef.current !== authoredUndo) { + ctx.feedback.setNotice('Generation undone') + } + return + } try { ctx.feedback.setBusyAction('undo-generation') ctx.feedback.setError(null) ctx.generation.setLatestNoteGenerationUndo(null) ctx.session.setNoteBody(undo.before) - const saved = await updateEntry(undo.entryId, storedBody) - if (writeVersion !== ctx.session.noteBodyWriteVersionRef.current) return - ctx.session.setNoteEntry(saved) - ctx.session.savedBodyRef.current = serializeRichEditorDocument(richEditorDocumentFromStoredBody(saved)) + const saved = await ctx.saveNoteBody(undo.before, { + manageBusy: false, + entryId: undo.entryId, + sessionId: ctx.session.noteEntry.sessionId, + expectedCurrentBody: serializeRichEditorDocument(undo.before), + }) + if (!saved) { + ctx.generation.setLatestNoteGenerationUndo(undo) + return + } + if (serializeRichEditorDocument(ctx.session.noteBodyRef.current) !== serializeRichEditorDocument(undo.before)) return ctx.feedback.setNotice('Generation undone') } catch (cause) { - if (writeVersion !== ctx.session.noteBodyWriteVersionRef.current) return ctx.feedback.setError(formatError(cause)) ctx.generation.setLatestNoteGenerationUndo(undo) } finally { - if (writeVersion === ctx.session.noteBodyWriteVersionRef.current) ctx.feedback.setBusyAction(null) + ctx.feedback.setBusyAction(null) } } @@ -220,13 +387,7 @@ export function createGenerationActions(ctx: GenerationActionsContext) { const storedBody = richEditorDocumentToStoredBody(draftDocument) const richDraft = { ...result.draft, ...storedBody } mergeDraft(richDraft) - void updateDraft(richDraft.id, storedBody) - .then((saved) => { - if (ctx.session.activeSessionIdRef.current === saved.sessionId) mergeDraft(saved) - }) - .catch((cause) => { - if (ctx.session.activeSessionIdRef.current === richDraft.sessionId) ctx.feedback.setError(formatError(cause)) - }) + void ctx.canonicalizeGeneratedDraft(richDraft) ctx.navigation.setActiveView('testware') ctx.feedback.setNotice('Testware generated') } else if (result.finding && ctx.session.activeSessionIdRef.current === result.finding.sessionId) { @@ -234,13 +395,7 @@ export function createGenerationActions(ctx: GenerationActionsContext) { const storedBody = richEditorDocumentToStoredBody(findingDocument) const richFinding = { ...result.finding, ...storedBody } mergeFinding(richFinding) - void updateFinding(richFinding.id, storedBody) - .then((saved) => { - if (ctx.session.activeSessionIdRef.current === saved.sessionId) mergeFinding(saved) - }) - .catch((cause) => { - if (ctx.session.activeSessionIdRef.current === richFinding.sessionId) ctx.feedback.setError(formatError(cause)) - }) + void ctx.canonicalizeGeneratedFinding(richFinding) ctx.navigation.setActiveView('findings') ctx.feedback.setNotice('Finding created') } else if (result.noteEntry && ctx.session.noteEntryIdRef.current === result.noteEntry.id) { @@ -298,7 +453,7 @@ export function createGenerationActions(ctx: GenerationActionsContext) { } } - return { handleAiAction, handleCancelGenerationJob, handleUndoLatestNoteGeneration, reconcileActiveJobs, storeGenerationStatus } + return { captureActiveJobs, handleAiAction, handleCancelGenerationJob, handleUndoLatestNoteGeneration, reconcileActiveJobs, storeGenerationStatus } } export function useGenerationActions(ctx: GenerationActionsContext) { diff --git a/frontend/src/app/generationActions.types.ts b/frontend/src/app/generationActions.types.ts new file mode 100644 index 0000000..90684d9 --- /dev/null +++ b/frontend/src/app/generationActions.types.ts @@ -0,0 +1,53 @@ +import type { RichEditorDocument } from '../editor/editorDocument' +import type { Draft, Finding } from '../tauri' +import type { + AiSelection, + GenerationWorkspace, + RecordWorkspace, + SessionWorkspace, + SummaryRecoveryCoordinator, + WorkflowFeedback, + WorkflowNavigation, +} from './types' + +export type GenerationActionsContext = { + session: Pick< + SessionWorkspace, + | 'activeSession' + | 'activeSessionIdRef' + | 'noteBodyRef' + | 'noteEntry' + | 'noteEntryIdRef' + | 'savedBodyRef' + | 'setNoteBody' + | 'setNoteEntry' + > + records: Pick< + RecordWorkspace, + | 'dirtyDraftIdsRef' + | 'dirtyFindingIdsRef' + | 'draftsRef' + | 'findingsRef' + | 'setDrafts' + | 'setFindings' + | 'setFindingCount' + | 'setTestwareDraftCount' + > + generation: GenerationWorkspace + latestNoteGenerationUndoRef: { current: GenerationWorkspace['latestNoteGenerationUndo'] } + summaryRecovery: SummaryRecoveryCoordinator + selection: AiSelection + feedback: WorkflowFeedback + navigation: WorkflowNavigation + saveNoteNow: (options?: { manageBusy?: boolean }) => Promise + saveNoteBody: (body: RichEditorDocument, options?: { + manageBusy?: boolean + entryId?: string + sessionId?: string + expectedCurrentBody?: string + allowRecoveryWrite?: boolean + }) => Promise + adoptCanonicalNoteBody: (body: RichEditorDocument, entryId: string, sessionId: string) => void + canonicalizeGeneratedDraft: (draft: Draft) => Promise + canonicalizeGeneratedFinding: (finding: Finding) => Promise +} diff --git a/frontend/src/app/recordActions.coordination.ts b/frontend/src/app/recordActions.coordination.ts new file mode 100644 index 0000000..d3e6210 --- /dev/null +++ b/frontend/src/app/recordActions.coordination.ts @@ -0,0 +1,276 @@ +import { updateDraft, updateFinding, type Draft, type Finding } from '../tauri' +import { + managedAttachmentImagesInDocument, + richEditorDocumentFromStoredBody, + type StoredRichBody, +} from '../editor/editorDocument' +import { formatError } from '../ui/format' +import type { InlineImageMaterialization } from './attachmentActions' +import type { RecordActionsContext } from './recordActions' + +export type RecordAttachmentOwner = { kind: 'draft' | 'finding'; id: string } + +type RecordIntent = { + generation: number + kind: 'save' | 'discard' | 'canonicalize' | 'delete' + restore: T | null + compensationPending?: boolean +} + +export function createRecordActionCoordination(ctx: RecordActionsContext) { + const draftIntents = new Map>() + const findingIntents = new Map>() + const pendingImportedAttachmentIds = new Map>() + const pendingWriteOperations = new Set>() + + function attachmentOwnerKey(owner: RecordAttachmentOwner) { + return `${owner.kind}:${owner.id}` + } + + function registerImportedRecordAttachment(owner: RecordAttachmentOwner, attachmentId: string) { + const key = attachmentOwnerKey(owner) + const pending = pendingImportedAttachmentIds.get(key) ?? new Set() + pending.add(attachmentId) + pendingImportedAttachmentIds.set(key, pending) + } + + async function settleImportedRecordAttachments( + owner: RecordAttachmentOwner, + record: StoredRichBody | null, + ): Promise { + const key = attachmentOwnerKey(owner) + const pending = pendingImportedAttachmentIds.get(key) + if (!pending?.size) return true + const attachmentIds = Array.from(pending) + const staleIds = attachmentsNotReferencedByRecord(attachmentIds, record) + if (!await ctx.cleanupMaterializedAttachments(staleIds)) return false + for (const attachmentId of attachmentIds) pending.delete(attachmentId) + if (pending.size === 0) pendingImportedAttachmentIds.delete(key) + return true + } + + function trackWrite(operation: Promise): Promise { + const tracked = operation.finally(() => pendingWriteOperations.delete(tracked)) + pendingWriteOperations.add(tracked) + return tracked + } + + async function waitForPendingRecordWrites(): Promise { + while (pendingWriteOperations.size > 0) { + await Promise.allSettled(Array.from(pendingWriteOperations)) + } + } + + function reserveDraftIntent(id: string, kind: RecordIntent['kind'], restore: Draft | null = null) { + const previous = draftIntents.get(id) + const intent: RecordIntent = { + generation: (previous?.generation ?? 0) + 1, + kind, + restore: kind === 'save' ? restore ?? previous?.restore ?? null : restore, + compensationPending: kind === 'save' ? previous?.compensationPending : undefined, + } + draftIntents.set(id, intent) + return intent.generation + } + + function reserveFindingIntent(id: string, kind: RecordIntent['kind'], restore: Finding | null = null) { + const previous = findingIntents.get(id) + const intent: RecordIntent = { + generation: (previous?.generation ?? 0) + 1, + kind, + restore: kind === 'save' ? restore ?? previous?.restore ?? null : restore, + compensationPending: kind === 'save' ? previous?.compensationPending : undefined, + } + findingIntents.set(id, intent) + return intent.generation + } + + function markDraftCompensationPending(draft: Draft, cause: unknown) { + const intent = draftIntents.get(draft.id) + if (intent) intent.compensationPending = true + if (ctx.session.activeSessionIdRef.current !== draft.sessionId) { + ctx.feedback.setError(`Could not reconcile the latest Testware write. ${formatError(cause)}`) + return + } + const hasNewerLocalEdit = ctx.records.dirtyDraftIdsRef.current.has(draft.id) + ctx.records.dirtyDraftIdsRef.current.add(draft.id) + if (!hasNewerLocalEdit) { + ctx.records.setDrafts((previous) => { + const next = replaceRecord(previous, draft) + ctx.records.draftsRef.current = next + return next + }) + } + ctx.feedback.setError(`Could not reconcile the latest Testware write. ${formatError(cause)}`) + } + + function markFindingCompensationPending(finding: Finding, cause: unknown) { + const intent = findingIntents.get(finding.id) + if (intent) intent.compensationPending = true + if (ctx.session.activeSessionIdRef.current !== finding.sessionId) { + ctx.feedback.setError(`Could not reconcile the latest Finding write. ${formatError(cause)}`) + return + } + const hasNewerLocalEdit = ctx.records.dirtyFindingIdsRef.current.has(finding.id) + ctx.records.dirtyFindingIdsRef.current.add(finding.id) + if (!hasNewerLocalEdit) { + ctx.records.setFindings((previous) => { + const next = replaceRecord(previous, finding) + ctx.records.findingsRef.current = next + return next + }) + } + ctx.feedback.setError(`Could not reconcile the latest Finding write. ${formatError(cause)}`) + } + + async function reconcileDraftIntent(id: string): Promise { + const intent = draftIntents.get(id) + if (!intent?.restore) return true + try { + const restored = await trackWrite(updateDraft(id, draftPersistencePatch(intent.restore))) + if (draftIntents.get(id) !== intent) return reconcileDraftIntent(id) + if (ctx.session.activeSessionIdRef.current === restored.sessionId) { + ctx.records.savedDraftsRef.current = replaceRecord(ctx.records.savedDraftsRef.current, restored) + } + const hasNewerLocalEdit = ctx.records.dirtyDraftIdsRef.current.has(id) + if (ctx.session.activeSessionIdRef.current === restored.sessionId && !hasNewerLocalEdit) { + ctx.records.setDrafts((previous) => { + const next = replaceRecord(previous, restored) + ctx.records.draftsRef.current = next + return next + }) + } + if (!hasNewerLocalEdit) ctx.records.dirtyDraftIdsRef.current.delete(id) + intent.compensationPending = false + return true + } catch (cause) { + if (draftIntents.get(id) !== intent) return reconcileDraftIntent(id) + markDraftCompensationPending(intent.restore, cause) + return false + } + } + + async function reconcileFindingIntent(id: string): Promise { + const intent = findingIntents.get(id) + if (!intent?.restore) return true + try { + const restored = await trackWrite(updateFinding(id, findingPersistencePatch(intent.restore))) + if (findingIntents.get(id) !== intent) return reconcileFindingIntent(id) + if (ctx.session.activeSessionIdRef.current === restored.sessionId) { + ctx.records.savedFindingsRef.current = replaceRecord(ctx.records.savedFindingsRef.current, restored) + } + const hasNewerLocalEdit = ctx.records.dirtyFindingIdsRef.current.has(id) + if (ctx.session.activeSessionIdRef.current === restored.sessionId && !hasNewerLocalEdit) { + ctx.records.setFindings((previous) => { + const next = replaceRecord(previous, restored) + ctx.records.findingsRef.current = next + return next + }) + } + if (!hasNewerLocalEdit) ctx.records.dirtyFindingIdsRef.current.delete(id) + intent.compensationPending = false + return true + } catch (cause) { + if (findingIntents.get(id) !== intent) return reconcileFindingIntent(id) + markFindingCompensationPending(intent.restore, cause) + return false + } + } + + async function retryPendingRecordCompensations(sessionId: string): Promise { + for (const [id, intent] of draftIntents) { + if (intent.restore?.sessionId !== sessionId || !intent.compensationPending) continue + if (!await reconcileDraftIntent(id)) return false + } + for (const [id, intent] of findingIntents) { + if (intent.restore?.sessionId !== sessionId || !intent.compensationPending) continue + if (!await reconcileFindingIntent(id)) return false + } + return true + } + + function hasPendingRecordCompensations(): boolean { + return pendingWriteOperations.size > 0 + || Array.from(draftIntents.values()).some((intent) => intent.compensationPending) + || Array.from(findingIntents.values()).some((intent) => intent.compensationPending) + } + + async function retryAllPendingRecordCompensations(): Promise { + let reconciled = true + for (const [id, intent] of draftIntents) { + if (intent.compensationPending && !await reconcileDraftIntent(id)) reconciled = false + } + for (const [id, intent] of findingIntents) { + if (intent.compensationPending && !await reconcileFindingIntent(id)) reconciled = false + } + return reconciled + } + + async function materializeRecordBody( + record: StoredRichBody, + isCurrent: () => boolean, + ): Promise { + const document = richEditorDocumentFromStoredBody(record) + return ctx.materializeInlineImages(document, { entryId: null, isCurrent }) + } + + return { + attachmentsNotReferencedByRecord, + draftIntents, + draftPersistencePatch, + findingIntents, + findingPersistencePatch, + hasPendingRecordCompensations, + markDraftCompensationPending, + markFindingCompensationPending, + materializeRecordBody, + reconcileDraftIntent, + reconcileFindingIntent, + registerImportedRecordAttachment, + replaceRecord, + reserveDraftIntent, + reserveFindingIntent, + retryAllPendingRecordCompensations, + retryPendingRecordCompensations, + settleImportedRecordAttachments, + trackWrite, + waitForPendingRecordWrites, + } +} + +export type RecordActionCoordination = ReturnType + +function replaceRecord(records: T[], saved: T): T[] { + return records.some((record) => record.id === saved.id) + ? records.map((record) => record.id === saved.id ? saved : record) + : [saved, ...records] +} + +function draftPersistencePatch(draft: Pick) { + return { + title: draft.title, + body: draft.body, + bodyJson: draft.bodyJson, + bodyFormat: draft.bodyFormat, + } +} + +function findingPersistencePatch(finding: Finding) { + return { + ...draftPersistencePatch(finding), + kind: finding.kind, + metadataJson: finding.metadataJson, + } +} + +function attachmentsNotReferencedByRecord( + attachmentIds: string[], + record: StoredRichBody | null | undefined, +): string[] { + if (!record) return attachmentIds + const referencedIds = new Set( + managedAttachmentImagesInDocument(richEditorDocumentFromStoredBody(record)) + .map((image) => image.attachmentId), + ) + return attachmentIds.filter((id) => !referencedIds.has(id)) +} diff --git a/frontend/src/app/recordActions.drafts.ts b/frontend/src/app/recordActions.drafts.ts new file mode 100644 index 0000000..ead909c --- /dev/null +++ b/frontend/src/app/recordActions.drafts.ts @@ -0,0 +1,227 @@ +import { deleteDraft, updateDraft, type Draft } from '../tauri' +import { richEditorDocumentToStoredBody } from '../editor/editorDocument' +import { formatError } from '../ui/format' +import type { RichRecordPatch } from './types' +import type { RecordActionsContext } from './recordActions' +import type { RecordActionCoordination } from './recordActions.coordination' + +type RecordPersistResult = 'saved' | 'superseded' | 'failed' + +export function createDraftRecordActions( + ctx: RecordActionsContext, + coordination: RecordActionCoordination, +) { + const intents = coordination.draftIntents + + async function persistDraft(draft: Draft): Promise { + if (intents.get(draft.id)?.kind === 'delete') return 'superseded' + const intentGeneration = coordination.reserveDraftIntent(draft.id, 'save') + let importedAttachmentIds: string[] = [] + try { + ctx.feedback.setError(null) + const materialized = await coordination.materializeRecordBody( + draft, + () => intents.get(draft.id)?.generation === intentGeneration, + ) + importedAttachmentIds = materialized.importedAttachmentIds + if (intents.get(draft.id)?.generation !== intentGeneration) { + return await ctx.cleanupMaterializedAttachments(importedAttachmentIds) ? 'superseded' : 'failed' + } + const storedBody = richEditorDocumentToStoredBody(materialized.document) + const saved = await coordination.trackWrite(updateDraft(draft.id, { title: draft.title, ...storedBody })) + const latestIntent = intents.get(draft.id) + if (!latestIntent || latestIntent.generation !== intentGeneration) { + const reconciled = await coordination.reconcileDraftIntent(draft.id) + const desired = latestIntent?.restore + ?? ctx.records.draftsRef.current.find((item) => item.id === draft.id) + const cleaned = await ctx.cleanupMaterializedAttachments( + coordination.attachmentsNotReferencedByRecord(importedAttachmentIds, desired), + ) + return reconciled && cleaned ? 'superseded' : 'failed' + } + intents.set(draft.id, { ...latestIntent, restore: saved, compensationPending: false }) + if (ctx.session.activeSessionIdRef.current === saved.sessionId) { + ctx.records.savedDraftsRef.current = coordination.replaceRecord(ctx.records.savedDraftsRef.current, saved) + } + const current = ctx.records.draftsRef.current.find((item) => item.id === draft.id) + if (!current) { + return 'failed' + } + if (!draftEditableFieldsMatch(current, draft)) { + return await ctx.cleanupMaterializedAttachments( + coordination.attachmentsNotReferencedByRecord(importedAttachmentIds, current), + ) ? 'superseded' : 'failed' + } + if (!await coordination.settleImportedRecordAttachments({ kind: 'draft', id: draft.id }, saved)) return 'failed' + const settledIntent = intents.get(draft.id) + if (!settledIntent || settledIntent.generation !== intentGeneration) { + const reconciled = await coordination.reconcileDraftIntent(draft.id) + const desired = settledIntent?.restore + ?? ctx.records.draftsRef.current.find((item) => item.id === draft.id) + const cleaned = await ctx.cleanupMaterializedAttachments( + coordination.attachmentsNotReferencedByRecord(importedAttachmentIds, desired), + ) + return reconciled && cleaned ? 'superseded' : 'failed' + } + const currentAfterSettlement = ctx.records.draftsRef.current.find((item) => item.id === draft.id) + if (!currentAfterSettlement || !draftEditableFieldsMatch(currentAfterSettlement, draft)) { + return await ctx.cleanupMaterializedAttachments( + coordination.attachmentsNotReferencedByRecord(importedAttachmentIds, currentAfterSettlement), + ) ? 'superseded' : 'failed' + } + ctx.records.dirtyDraftIdsRef.current.delete(draft.id) + if (ctx.session.activeSessionIdRef.current === saved.sessionId) { + ctx.records.setDrafts((previous) => { + const nextDrafts = previous.map((item) => (item.id === saved.id ? saved : item)) + ctx.records.draftsRef.current = nextDrafts + return nextDrafts + }) + } + return 'saved' + } catch (cause) { + await ctx.cleanupMaterializedAttachments(importedAttachmentIds) + const latestIntent = intents.get(draft.id) + const reconciled = !latestIntent?.restore || await coordination.reconcileDraftIntent(draft.id) + if (reconciled) ctx.feedback.setError(formatError(cause)) + return 'failed' + } + } + + async function handleSaveDraft(draft: Draft): Promise { + try { + ctx.feedback.setBusyAction(`draft:${draft.id}`) + const result = await persistDraft(draft) + if (result === 'saved') ctx.feedback.setNotice('Testware saved') + return result === 'saved' + } finally { + ctx.feedback.setBusyAction(null) + } + } + + function updateLocalDraft(id: string, patch: RichRecordPatch) { + if (intents.get(id)?.kind === 'delete') return + ctx.registerRecordEditIntent() + ctx.records.dirtyDraftIdsRef.current.add(id) + ctx.records.setDrafts((previous) => { + const nextDrafts = previous.map((draft) => (draft.id === id ? { ...draft, ...patch } : draft)) + ctx.records.draftsRef.current = nextDrafts + return nextDrafts + }) + } + + function discardLocalDraft(original: Draft) { + if (intents.get(original.id)?.kind === 'delete') return + const latestSaved = ctx.records.savedDraftsRef.current.find((draft) => draft.id === original.id) ?? original + const compensationPending = intents.get(original.id)?.compensationPending + coordination.reserveDraftIntent(original.id, 'discard', latestSaved) + if (compensationPending) { + intents.get(original.id)!.compensationPending = true + ctx.records.dirtyDraftIdsRef.current.add(original.id) + } else { + ctx.records.dirtyDraftIdsRef.current.delete(original.id) + } + ctx.records.setDrafts((previous) => { + const nextDrafts = previous.map((draft) => (draft.id === original.id ? latestSaved : draft)) + ctx.records.draftsRef.current = nextDrafts + return nextDrafts + }) + void coordination.settleImportedRecordAttachments({ kind: 'draft', id: original.id }, latestSaved) + if (compensationPending) { + ctx.feedback.setError('Save the restored Testware again before leaving.') + return + } + ctx.feedback.setNotice('Testware changes discarded') + } + + async function canonicalizeGeneratedDraft(draft: Draft): Promise { + if (intents.get(draft.id)?.kind === 'delete') return + const intentGeneration = coordination.reserveDraftIntent(draft.id, 'canonicalize', draft) + ctx.records.savedDraftsRef.current = coordination.replaceRecord(ctx.records.savedDraftsRef.current, draft) + try { + const saved = await coordination.trackWrite(updateDraft(draft.id, coordination.draftPersistencePatch(draft))) + const intent = intents.get(draft.id) + if (!intent || intent.generation !== intentGeneration) { + await coordination.reconcileDraftIntent(draft.id) + return + } + intents.set(draft.id, { ...intent, restore: saved, compensationPending: false }) + if (ctx.session.activeSessionIdRef.current === saved.sessionId) { + ctx.records.savedDraftsRef.current = coordination.replaceRecord(ctx.records.savedDraftsRef.current, saved) + } + if (ctx.session.activeSessionIdRef.current === saved.sessionId && !ctx.records.dirtyDraftIdsRef.current.has(saved.id)) { + ctx.records.setDrafts((previous) => { + const next = coordination.replaceRecord(previous, saved) + ctx.records.draftsRef.current = next + return next + }) + } + } catch (cause) { + const intent = intents.get(draft.id) + if (intent?.generation !== intentGeneration) return + coordination.markDraftCompensationPending(draft, cause) + } + } + + function requestDeleteDraft(draft: Draft) { + ctx.deletion.setDeleteConfirmation({ kind: 'draft', draft }) + } + + async function handleDeleteDraft(draft: Draft) { + const previousIntent = intents.get(draft.id) + const deletionGeneration = coordination.reserveDraftIntent(draft.id, 'delete') + let deleted = false + try { + ctx.feedback.setBusyAction(`delete-draft:${draft.id}`) + ctx.feedback.setError(null) + await deleteDraft(draft.id) + deleted = true + ctx.invalidateDraftLoads() + removeDeletedDraftFromWorkspace(draft) + const attachmentsSettled = await coordination.settleImportedRecordAttachments({ kind: 'draft', id: draft.id }, null) + ctx.feedback.setNotice(attachmentsSettled + ? 'Testware deleted' + : 'Testware deleted; image cleanup will retry before closing') + await ctx.loaders.loadDraftsForSession(draft.sessionId, { force: true, replace: true }) + } catch (cause) { + if (!deleted && intents.get(draft.id)?.generation === deletionGeneration) { + if (previousIntent) intents.set(draft.id, previousIntent) + else intents.delete(draft.id) + } + ctx.feedback.setError(formatError(cause)) + } finally { + ctx.feedback.setBusyAction(null) + } + } + + function removeDeletedDraftFromWorkspace(draft: Draft) { + ctx.records.dirtyDraftIdsRef.current.delete(draft.id) + ctx.records.savedDraftsRef.current = ctx.records.savedDraftsRef.current.filter((item) => item.id !== draft.id) + if (ctx.session.activeSessionIdRef.current !== draft.sessionId) return + const wasVisible = ctx.records.draftsRef.current.some((item) => item.id === draft.id) + const next = ctx.records.draftsRef.current.filter((item) => item.id !== draft.id) + ctx.records.draftsRef.current = next + ctx.records.setDrafts(next) + if (wasVisible && draft.kind === 'testware') { + ctx.records.setTestwareDraftCount((count) => Math.max(0, count - 1)) + } + } + + return { + canonicalizeGeneratedDraft, + discardLocalDraft, + handleDeleteDraft, + handleSaveDraft, + persistDraft, + requestDeleteDraft, + updateLocalDraft, + } +} + +function draftEditableFieldsMatch(left: Draft, right: Draft): boolean { + return ( + left.title === right.title && + left.body === right.body && + left.bodyJson === right.bodyJson && + left.bodyFormat === right.bodyFormat + ) +} diff --git a/frontend/src/app/recordActions.findings.ts b/frontend/src/app/recordActions.findings.ts new file mode 100644 index 0000000..9b549d9 --- /dev/null +++ b/frontend/src/app/recordActions.findings.ts @@ -0,0 +1,232 @@ +import { deleteFinding, updateFinding, type Finding } from '../tauri' +import { richEditorDocumentToStoredBody } from '../editor/editorDocument' +import { formatError } from '../ui/format' +import type { FindingRecordPatch } from './types' +import type { RecordActionsContext } from './recordActions' +import type { RecordActionCoordination } from './recordActions.coordination' + +type RecordPersistResult = 'saved' | 'superseded' | 'failed' + +export function createFindingRecordActions( + ctx: RecordActionsContext, + coordination: RecordActionCoordination, +) { + const intents = coordination.findingIntents + + async function persistFinding(finding: Finding): Promise { + if (intents.get(finding.id)?.kind === 'delete') return 'superseded' + const intentGeneration = coordination.reserveFindingIntent(finding.id, 'save') + let importedAttachmentIds: string[] = [] + try { + ctx.feedback.setError(null) + const materialized = await coordination.materializeRecordBody( + finding, + () => intents.get(finding.id)?.generation === intentGeneration, + ) + importedAttachmentIds = materialized.importedAttachmentIds + if (intents.get(finding.id)?.generation !== intentGeneration) { + return await ctx.cleanupMaterializedAttachments(importedAttachmentIds) ? 'superseded' : 'failed' + } + const storedBody = richEditorDocumentToStoredBody(materialized.document) + const saved = await coordination.trackWrite(updateFinding(finding.id, { + title: finding.title, + ...storedBody, + kind: finding.kind, + metadataJson: finding.metadataJson, + })) + const latestIntent = intents.get(finding.id) + if (!latestIntent || latestIntent.generation !== intentGeneration) { + const reconciled = await coordination.reconcileFindingIntent(finding.id) + const desired = latestIntent?.restore + ?? ctx.records.findingsRef.current.find((item) => item.id === finding.id) + const cleaned = await ctx.cleanupMaterializedAttachments( + coordination.attachmentsNotReferencedByRecord(importedAttachmentIds, desired), + ) + return reconciled && cleaned ? 'superseded' : 'failed' + } + intents.set(finding.id, { ...latestIntent, restore: saved, compensationPending: false }) + if (ctx.session.activeSessionIdRef.current === saved.sessionId) { + ctx.records.savedFindingsRef.current = coordination.replaceRecord(ctx.records.savedFindingsRef.current, saved) + } + const current = ctx.records.findingsRef.current.find((item) => item.id === finding.id) + if (!current) { + return 'failed' + } + if (!findingEditableFieldsMatch(current, finding)) { + return await ctx.cleanupMaterializedAttachments( + coordination.attachmentsNotReferencedByRecord(importedAttachmentIds, current), + ) ? 'superseded' : 'failed' + } + if (!await coordination.settleImportedRecordAttachments({ kind: 'finding', id: finding.id }, saved)) return 'failed' + const settledIntent = intents.get(finding.id) + if (!settledIntent || settledIntent.generation !== intentGeneration) { + const reconciled = await coordination.reconcileFindingIntent(finding.id) + const desired = settledIntent?.restore + ?? ctx.records.findingsRef.current.find((item) => item.id === finding.id) + const cleaned = await ctx.cleanupMaterializedAttachments( + coordination.attachmentsNotReferencedByRecord(importedAttachmentIds, desired), + ) + return reconciled && cleaned ? 'superseded' : 'failed' + } + const currentAfterSettlement = ctx.records.findingsRef.current.find((item) => item.id === finding.id) + if (!currentAfterSettlement || !findingEditableFieldsMatch(currentAfterSettlement, finding)) { + return await ctx.cleanupMaterializedAttachments( + coordination.attachmentsNotReferencedByRecord(importedAttachmentIds, currentAfterSettlement), + ) ? 'superseded' : 'failed' + } + ctx.records.dirtyFindingIdsRef.current.delete(finding.id) + if (ctx.session.activeSessionIdRef.current === saved.sessionId) { + ctx.records.setFindings((previous) => { + const nextFindings = previous.map((item) => (item.id === saved.id ? saved : item)) + ctx.records.findingsRef.current = nextFindings + return nextFindings + }) + } + return 'saved' + } catch (cause) { + await ctx.cleanupMaterializedAttachments(importedAttachmentIds) + const latestIntent = intents.get(finding.id) + const reconciled = !latestIntent?.restore || await coordination.reconcileFindingIntent(finding.id) + if (reconciled) ctx.feedback.setError(formatError(cause)) + return 'failed' + } + } + + async function handleSaveFinding(finding: Finding): Promise { + try { + ctx.feedback.setBusyAction(`finding:${finding.id}`) + const result = await persistFinding(finding) + if (result === 'saved') ctx.feedback.setNotice('Finding saved') + return result === 'saved' + } finally { + ctx.feedback.setBusyAction(null) + } + } + + function updateLocalFinding(id: string, patch: FindingRecordPatch) { + if (intents.get(id)?.kind === 'delete') return + ctx.registerRecordEditIntent() + ctx.records.dirtyFindingIdsRef.current.add(id) + ctx.records.setFindings((previous) => { + const nextFindings = previous.map((finding) => (finding.id === id ? { ...finding, ...patch } : finding)) + ctx.records.findingsRef.current = nextFindings + return nextFindings + }) + } + + function discardLocalFinding(original: Finding) { + if (intents.get(original.id)?.kind === 'delete') return + const latestSaved = ctx.records.savedFindingsRef.current.find((finding) => finding.id === original.id) ?? original + const compensationPending = intents.get(original.id)?.compensationPending + coordination.reserveFindingIntent(original.id, 'discard', latestSaved) + if (compensationPending) { + intents.get(original.id)!.compensationPending = true + ctx.records.dirtyFindingIdsRef.current.add(original.id) + } else { + ctx.records.dirtyFindingIdsRef.current.delete(original.id) + } + ctx.records.setFindings((previous) => { + const nextFindings = previous.map((finding) => (finding.id === original.id ? latestSaved : finding)) + ctx.records.findingsRef.current = nextFindings + return nextFindings + }) + void coordination.settleImportedRecordAttachments({ kind: 'finding', id: original.id }, latestSaved) + if (compensationPending) { + ctx.feedback.setError('Save the restored Finding again before leaving.') + return + } + ctx.feedback.setNotice('Finding changes discarded') + } + + async function canonicalizeGeneratedFinding(finding: Finding): Promise { + if (intents.get(finding.id)?.kind === 'delete') return + const intentGeneration = coordination.reserveFindingIntent(finding.id, 'canonicalize', finding) + ctx.records.savedFindingsRef.current = coordination.replaceRecord(ctx.records.savedFindingsRef.current, finding) + try { + const saved = await coordination.trackWrite(updateFinding(finding.id, coordination.findingPersistencePatch(finding))) + const intent = intents.get(finding.id) + if (!intent || intent.generation !== intentGeneration) { + await coordination.reconcileFindingIntent(finding.id) + return + } + intents.set(finding.id, { ...intent, restore: saved, compensationPending: false }) + if (ctx.session.activeSessionIdRef.current === saved.sessionId) { + ctx.records.savedFindingsRef.current = coordination.replaceRecord(ctx.records.savedFindingsRef.current, saved) + } + if (ctx.session.activeSessionIdRef.current === saved.sessionId && !ctx.records.dirtyFindingIdsRef.current.has(saved.id)) { + ctx.records.setFindings((previous) => { + const next = coordination.replaceRecord(previous, saved) + ctx.records.findingsRef.current = next + return next + }) + } + } catch (cause) { + const intent = intents.get(finding.id) + if (intent?.generation !== intentGeneration) return + coordination.markFindingCompensationPending(finding, cause) + } + } + + function requestDeleteFinding(finding: Finding) { + ctx.deletion.setDeleteConfirmation({ kind: 'finding', finding }) + } + + async function handleDeleteFinding(finding: Finding) { + const previousIntent = intents.get(finding.id) + const deletionGeneration = coordination.reserveFindingIntent(finding.id, 'delete') + let deleted = false + try { + ctx.feedback.setBusyAction(`delete-finding:${finding.id}`) + ctx.feedback.setError(null) + await deleteFinding(finding.id) + deleted = true + ctx.invalidateFindingLoads() + removeDeletedFindingFromWorkspace(finding) + const attachmentsSettled = await coordination.settleImportedRecordAttachments({ kind: 'finding', id: finding.id }, null) + ctx.feedback.setNotice(attachmentsSettled + ? 'Finding deleted' + : 'Finding deleted; image cleanup will retry before closing') + await ctx.loaders.loadFindingsForSession(finding.sessionId, { force: true, replace: true }) + } catch (cause) { + if (!deleted && intents.get(finding.id)?.generation === deletionGeneration) { + if (previousIntent) intents.set(finding.id, previousIntent) + else intents.delete(finding.id) + } + ctx.feedback.setError(formatError(cause)) + } finally { + ctx.feedback.setBusyAction(null) + } + } + + function removeDeletedFindingFromWorkspace(finding: Finding) { + ctx.records.dirtyFindingIdsRef.current.delete(finding.id) + ctx.records.savedFindingsRef.current = ctx.records.savedFindingsRef.current.filter((item) => item.id !== finding.id) + if (ctx.session.activeSessionIdRef.current !== finding.sessionId) return + const wasVisible = ctx.records.findingsRef.current.some((item) => item.id === finding.id) + const next = ctx.records.findingsRef.current.filter((item) => item.id !== finding.id) + ctx.records.findingsRef.current = next + ctx.records.setFindings(next) + if (wasVisible) ctx.records.setFindingCount((count) => Math.max(0, count - 1)) + } + + return { + canonicalizeGeneratedFinding, + discardLocalFinding, + handleDeleteFinding, + handleSaveFinding, + persistFinding, + requestDeleteFinding, + updateLocalFinding, + } +} + +function findingEditableFieldsMatch(left: Finding, right: Finding): boolean { + return ( + left.title === right.title && + left.body === right.body && + left.bodyJson === right.bodyJson && + left.bodyFormat === right.bodyFormat && + left.kind === right.kind && + left.metadataJson === right.metadataJson + ) +} diff --git a/frontend/src/app/recordActions.ts b/frontend/src/app/recordActions.ts index 777e90c..fcc7e2f 100644 --- a/frontend/src/app/recordActions.ts +++ b/frontend/src/app/recordActions.ts @@ -1,27 +1,25 @@ -import { - createDraft, - createFinding, - deleteDraft, - deleteFinding, - updateDraft, - updateFinding, - type Draft, - type Finding, - type Session, -} from '../tauri' +import { createDraft, createFinding, type Draft, type Finding, type Session } from '../tauri' import { emptyRichEditorDocument, richEditorDocumentFromHtml, - richEditorDocumentFromStoredBody, richEditorDocumentToStoredBody, - type StoredRichBody, type RichEditorDocument, } from '../editor/editorDocument' import { formatError, nextUntitledRecordTitle } from '../ui/format' +import type { BusyAction, MainView } from '../ui/types' import { renderPrefilledFinding, renderPrefilledTestware } from '../workflows/prefillTemplates' -import type { DeletionWorkspace, FindingRecordPatch, RecordWorkspace, RichRecordPatch, SessionWorkspace, WorkflowFeedback, WorkflowNavigation } from './types' +import type { InlineImageMaterialization } from './attachmentActions' +import { createRecordActionCoordination } from './recordActions.coordination' +import { createDraftRecordActions } from './recordActions.drafts' +import { createFindingRecordActions } from './recordActions.findings' +import type { + DeletionWorkspace, + RecordWorkspace, + SessionWorkspace, + WorkflowFeedback, + WorkflowNavigation, +} from './types' import { useStableCapability } from './useStableCapability' -import type { BusyAction, MainView } from '../ui/types' export type RecordLoaders = { loadDraftsForSession: (sessionId: string, options?: { force?: boolean; replace?: boolean }) => Promise @@ -30,10 +28,8 @@ export type RecordLoaders = { type InlineImageMaterializer = ( document: RichEditorDocument, - options?: { entryId?: string | null; updateNoteBody?: boolean }, -) => Promise - -type RecordPersistResult = 'saved' | 'superseded' | 'failed' + options?: { entryId?: string | null; isCurrent?: () => boolean }, +) => Promise export type RecordActionsContext = { session: Pick @@ -47,17 +43,27 @@ export type RecordActionsContext = { | 'savedFindingsRef' | 'setDrafts' | 'setFindings' + | 'setTestwareDraftCount' + | 'setFindingCount' > feedback: WorkflowFeedback navigation: WorkflowNavigation deletion: DeletionWorkspace saveNoteNow: (options?: { manageBusy?: boolean }) => Promise + registerRecordEditIntent: () => void handleDeleteSession: (session: Session) => Promise materializeInlineImages: InlineImageMaterializer + cleanupMaterializedAttachments: (attachmentIds: string[]) => Promise + invalidateDraftLoads: () => void + invalidateFindingLoads: () => void loaders: RecordLoaders } export function createRecordActions(ctx: RecordActionsContext) { + const coordination = createRecordActionCoordination(ctx) + const draftActions = createDraftRecordActions(ctx, coordination) + const findingActions = createFindingRecordActions(ctx, coordination) + async function createRecordFromNote( busy: BusyAction, bodyDocument: RichEditorDocument, @@ -153,126 +159,25 @@ export function createRecordActions(ctx: RecordActionsContext) { ) } - async function persistDraft(draft: Draft): Promise { - try { - ctx.feedback.setError(null) - const storedBody = await materializeRecordBody(draft, ctx.materializeInlineImages) - const saved = await updateDraft(draft.id, { title: draft.title, ...storedBody }) - const current = ctx.records.draftsRef.current.find((item) => item.id === draft.id) - if (!current) return 'failed' - if (!draftEditableFieldsMatch(current, draft)) return 'superseded' - ctx.records.dirtyDraftIdsRef.current.delete(draft.id) - ctx.records.savedDraftsRef.current = replaceRecord(ctx.records.savedDraftsRef.current, saved) - if (ctx.session.activeSessionIdRef.current === saved.sessionId) { - ctx.records.setDrafts((previous) => { - const nextDrafts = previous.map((item) => (item.id === saved.id ? saved : item)) - ctx.records.draftsRef.current = nextDrafts - return nextDrafts - }) - } - return 'saved' - } catch (cause) { - ctx.feedback.setError(formatError(cause)) - return 'failed' - } - } - - async function handleSaveDraft(draft: Draft): Promise { - try { - ctx.feedback.setBusyAction(`draft:${draft.id}`) - const result = await persistDraft(draft) - if (result === 'saved') ctx.feedback.setNotice('Testware saved') - return result === 'saved' - } finally { - ctx.feedback.setBusyAction(null) - } - } - - function updateLocalDraft(id: string, patch: RichRecordPatch) { - ctx.records.dirtyDraftIdsRef.current.add(id) - ctx.records.setDrafts((previous) => { - const nextDrafts = previous.map((draft) => (draft.id === id ? { ...draft, ...patch } : draft)) - ctx.records.draftsRef.current = nextDrafts - return nextDrafts - }) - } - - function discardLocalDraft(original: Draft) { - ctx.records.dirtyDraftIdsRef.current.delete(original.id) - ctx.records.setDrafts((previous) => { - const nextDrafts = previous.map((draft) => (draft.id === original.id ? original : draft)) - ctx.records.draftsRef.current = nextDrafts - return nextDrafts - }) - ctx.feedback.setNotice('Testware changes discarded') - } - - async function persistFinding(finding: Finding): Promise { - try { - ctx.feedback.setError(null) - const storedBody = await materializeRecordBody(finding, ctx.materializeInlineImages) - const saved = await updateFinding(finding.id, { - title: finding.title, - ...storedBody, - kind: finding.kind, - metadataJson: finding.metadataJson, - }) - const current = ctx.records.findingsRef.current.find((item) => item.id === finding.id) - if (!current) return 'failed' - if (!findingEditableFieldsMatch(current, finding)) return 'superseded' - ctx.records.dirtyFindingIdsRef.current.delete(finding.id) - ctx.records.savedFindingsRef.current = replaceRecord(ctx.records.savedFindingsRef.current, saved) - if (ctx.session.activeSessionIdRef.current === saved.sessionId) { - ctx.records.setFindings((previous) => { - const nextFindings = previous.map((item) => (item.id === saved.id ? saved : item)) - ctx.records.findingsRef.current = nextFindings - return nextFindings - }) - } - return 'saved' - } catch (cause) { - ctx.feedback.setError(formatError(cause)) - return 'failed' - } - } - - async function handleSaveFinding(finding: Finding): Promise { - try { - ctx.feedback.setBusyAction(`finding:${finding.id}`) - const result = await persistFinding(finding) - if (result === 'saved') ctx.feedback.setNotice('Finding saved') - return result === 'saved' - } finally { - ctx.feedback.setBusyAction(null) - } - } - - function updateLocalFinding(id: string, patch: FindingRecordPatch) { - ctx.records.dirtyFindingIdsRef.current.add(id) - ctx.records.setFindings((previous) => { - const nextFindings = previous.map((finding) => (finding.id === id ? { ...finding, ...patch } : finding)) - ctx.records.findingsRef.current = nextFindings - return nextFindings - }) - } - - function discardLocalFinding(original: Finding) { - ctx.records.dirtyFindingIdsRef.current.delete(original.id) - ctx.records.setFindings((previous) => { - const nextFindings = previous.map((finding) => (finding.id === original.id ? original : finding)) - ctx.records.findingsRef.current = nextFindings - return nextFindings - }) - ctx.feedback.setNotice('Finding changes discarded') - } - function discardAllDirtyRecords() { const dirtyDraftIds = new Set(ctx.records.dirtyDraftIdsRef.current) const dirtyFindingIds = new Set(ctx.records.dirtyFindingIdsRef.current) const savedDrafts = new Map(ctx.records.savedDraftsRef.current.map((draft) => [draft.id, draft])) const savedFindings = new Map(ctx.records.savedFindingsRef.current.map((finding) => [finding.id, finding])) + const pendingDraftIds = new Set(Array.from(dirtyDraftIds).filter((id) => coordination.draftIntents.get(id)?.compensationPending)) + const pendingFindingIds = new Set(Array.from(dirtyFindingIds).filter((id) => coordination.findingIntents.get(id)?.compensationPending)) + for (const id of dirtyDraftIds) if (!pendingDraftIds.has(id)) coordination.reserveDraftIntent(id, 'discard', savedDrafts.get(id) ?? null) + for (const id of dirtyFindingIds) if (!pendingFindingIds.has(id)) coordination.reserveFindingIntent(id, 'discard', savedFindings.get(id) ?? null) ctx.records.dirtyDraftIdsRef.current.clear() ctx.records.dirtyFindingIdsRef.current.clear() + for (const id of pendingDraftIds) ctx.records.dirtyDraftIdsRef.current.add(id) + for (const id of pendingFindingIds) ctx.records.dirtyFindingIdsRef.current.add(id) + for (const id of dirtyDraftIds) { + void coordination.settleImportedRecordAttachments({ kind: 'draft', id }, savedDrafts.get(id) ?? null) + } + for (const id of dirtyFindingIds) { + void coordination.settleImportedRecordAttachments({ kind: 'finding', id }, savedFindings.get(id) ?? null) + } ctx.records.setDrafts((previous) => { const next = previous .filter((draft) => !dirtyDraftIds.has(draft.id) || savedDrafts.has(draft.id)) @@ -287,6 +192,10 @@ export function createRecordActions(ctx: RecordActionsContext) { ctx.records.findingsRef.current = next return next }) + if (pendingDraftIds.size > 0 || pendingFindingIds.size > 0) { + ctx.feedback.setError('A failed Record reconciliation must be saved again before leaving.') + return + } ctx.feedback.setNotice('Pending record changes discarded') } @@ -298,12 +207,12 @@ export function createRecordActions(ctx: RecordActionsContext) { let failed = false let allSaved = true for (const draft of dirtyDrafts) { - const result = await persistDraft(draft) + const result = await draftActions.persistDraft(draft) failed = result === 'failed' || failed allSaved = result === 'saved' && allSaved } for (const finding of dirtyFindings) { - const result = await persistFinding(finding) + const result = await findingActions.persistFinding(finding) failed = result === 'failed' || failed allSaved = result === 'saved' && allSaved } @@ -312,44 +221,6 @@ export function createRecordActions(ctx: RecordActionsContext) { return true } - function requestDeleteDraft(draft: Draft) { - ctx.deletion.setDeleteConfirmation({ kind: 'draft', draft }) - } - - async function handleDeleteDraft(draft: Draft) { - try { - ctx.feedback.setBusyAction(`delete-draft:${draft.id}`) - ctx.feedback.setError(null) - await deleteDraft(draft.id) - ctx.records.dirtyDraftIdsRef.current.delete(draft.id) - await ctx.loaders.loadDraftsForSession(draft.sessionId, { force: true, replace: true }) - ctx.feedback.setNotice('Testware deleted') - } catch (cause) { - ctx.feedback.setError(formatError(cause)) - } finally { - ctx.feedback.setBusyAction(null) - } - } - - function requestDeleteFinding(finding: Finding) { - ctx.deletion.setDeleteConfirmation({ kind: 'finding', finding }) - } - - async function handleDeleteFinding(finding: Finding) { - try { - ctx.feedback.setBusyAction(`delete-finding:${finding.id}`) - ctx.feedback.setError(null) - await deleteFinding(finding.id) - ctx.records.dirtyFindingIdsRef.current.delete(finding.id) - await ctx.loaders.loadFindingsForSession(finding.sessionId, { force: true, replace: true }) - ctx.feedback.setNotice('Finding deleted') - } catch (cause) { - ctx.feedback.setError(formatError(cause)) - } finally { - ctx.feedback.setBusyAction(null) - } - } - async function confirmDelete() { const confirmation = ctx.deletion.deleteConfirmation if (!confirmation) return @@ -358,66 +229,38 @@ export function createRecordActions(ctx: RecordActionsContext) { if (confirmation.kind === 'session') { await ctx.handleDeleteSession(confirmation.session) } else if (confirmation.kind === 'draft') { - await handleDeleteDraft(confirmation.draft) + await draftActions.handleDeleteDraft(confirmation.draft) } else { - await handleDeleteFinding(confirmation.finding) + await findingActions.handleDeleteFinding(confirmation.finding) } } return { confirmDelete, - discardLocalDraft, - discardLocalFinding, + canonicalizeGeneratedDraft: draftActions.canonicalizeGeneratedDraft, + canonicalizeGeneratedFinding: findingActions.canonicalizeGeneratedFinding, + discardLocalDraft: draftActions.discardLocalDraft, + discardLocalFinding: findingActions.discardLocalFinding, discardAllDirtyRecords, handleManualFinding, handleManualTestware, handlePrefillFindingFromNote, handlePrefillTestwareFromNote, + hasPendingRecordCompensations: coordination.hasPendingRecordCompensations, saveDirtyRecordsNow, - handleSaveDraft, - handleSaveFinding, - requestDeleteDraft, - requestDeleteFinding, - updateLocalDraft, - updateLocalFinding, + handleSaveDraft: draftActions.handleSaveDraft, + handleSaveFinding: findingActions.handleSaveFinding, + requestDeleteDraft: draftActions.requestDeleteDraft, + requestDeleteFinding: findingActions.requestDeleteFinding, + registerImportedRecordAttachment: coordination.registerImportedRecordAttachment, + retryAllPendingRecordCompensations: coordination.retryAllPendingRecordCompensations, + retryPendingRecordCompensations: coordination.retryPendingRecordCompensations, + updateLocalDraft: draftActions.updateLocalDraft, + updateLocalFinding: findingActions.updateLocalFinding, + waitForPendingRecordWrites: coordination.waitForPendingRecordWrites, } } -function replaceRecord(records: T[], saved: T): T[] { - return records.some((record) => record.id === saved.id) - ? records.map((record) => record.id === saved.id ? saved : record) - : [saved, ...records] -} - export function useRecordActions(ctx: RecordActionsContext) { return useStableCapability(ctx, createRecordActions) } - -async function materializeRecordBody( - record: StoredRichBody, - materializeInlineImages: InlineImageMaterializer, -): Promise { - const document = richEditorDocumentFromStoredBody(record) - const materialized = await materializeInlineImages(document, { entryId: null }) - return richEditorDocumentToStoredBody(materialized) -} - -function draftEditableFieldsMatch(left: Draft, right: Draft): boolean { - return ( - left.title === right.title && - left.body === right.body && - left.bodyJson === right.bodyJson && - left.bodyFormat === right.bodyFormat - ) -} - -function findingEditableFieldsMatch(left: Finding, right: Finding): boolean { - return ( - left.title === right.title && - left.body === right.body && - left.bodyJson === right.bodyJson && - left.bodyFormat === right.bodyFormat && - left.kind === right.kind && - left.metadataJson === right.metadataJson - ) -} diff --git a/frontend/src/app/sessionActions.saving.ts b/frontend/src/app/sessionActions.saving.ts new file mode 100644 index 0000000..da72e73 --- /dev/null +++ b/frontend/src/app/sessionActions.saving.ts @@ -0,0 +1,265 @@ +import { + emptyRichEditorDocument, + managedAttachmentImagesInDocument, + parseRichEditorDocument, + serializeRichEditorDocument, + type RichEditorDocument, +} from '../editor/editorDocument' +import { formatError } from '../ui/format' +import type { SessionActionsContext } from './sessionActions' +import type { SessionWriteActions } from './sessionActions.writes' + +export function createSessionSaveActions(ctx: SessionActionsContext, writes: SessionWriteActions) { + const pendingImportedAttachmentIds = new Map>() + + function rememberImportedAttachments(entryId: string, attachmentIds: string[]) { + if (attachmentIds.length === 0) return + const pending = pendingImportedAttachmentIds.get(entryId) ?? new Set() + for (const id of attachmentIds) pending.add(id) + pendingImportedAttachmentIds.set(entryId, pending) + } + + function registerImportedNoteAttachment(entryId: string, attachmentId: string) { + rememberImportedAttachments(entryId, [attachmentId]) + } + + function clearPendingImportedAttachments(entryId: string) { + pendingImportedAttachmentIds.delete(entryId) + } + + function markReferencedAttachmentsSaved(entryId: string, body: RichEditorDocument) { + const pending = pendingImportedAttachmentIds.get(entryId) + if (!pending) return + for (const image of managedAttachmentImagesInDocument(body)) pending.delete(image.attachmentId) + if (pending.size === 0) pendingImportedAttachmentIds.delete(entryId) + } + + async function cleanupPendingAttachmentsNotIn(entryId: string, body: RichEditorDocument): Promise { + const pending = pendingImportedAttachmentIds.get(entryId) + if (!pending?.size) return true + const retained = new Set(managedAttachmentImagesInDocument(body).map((image) => image.attachmentId)) + const stale = Array.from(pending).filter((id) => !retained.has(id)) + if (!await ctx.cleanupMaterializedAttachments(stale)) return false + for (const id of stale) pending.delete(id) + if (pending.size === 0) pendingImportedAttachmentIds.delete(entryId) + return true + } + + async function savePendingSessionEdits(): Promise { + if (ctx.session.forcedPendingSaveRef.current) return ctx.session.forcedPendingSaveRef.current + + const pending = flushPendingSessionEdits().finally(() => { + if (ctx.session.forcedPendingSaveRef.current === pending) { + ctx.session.forcedPendingSaveRef.current = null + } + }) + ctx.session.forcedPendingSaveRef.current = pending + return pending + } + + async function flushPendingSessionEdits(): Promise { + ctx.session.suppressAmbientNoteSaveRef.current = true + try { + do { + const flushedNote = await saveNoteNow({ manageBusy: false }) + if (!flushedNote) return false + const flushedRecords = await ctx.saveDirtyRecordsNow() + if (!flushedRecords) return false + } while (hasPendingSessionEdits()) + return true + } finally { + ctx.session.suppressAmbientNoteSaveRef.current = false + } + } + + function hasPendingSessionEdits(): boolean { + const titleDirty = Boolean( + ctx.session.activeSession + && ctx.session.sessionTitleRef.current !== ctx.session.savedTitleRef.current, + ) + const bodyDirty = Boolean( + ctx.session.noteEntry + && serializeRichEditorDocument(ctx.session.noteBodyRef.current) !== ctx.session.savedBodyRef.current, + ) + return titleDirty + || bodyDirty + || ctx.records.dirtyDraftIdsRef.current.size > 0 + || ctx.records.dirtyFindingIdsRef.current.size > 0 + } + + function discardPendingSessionEdits(): boolean | Promise { + ctx.session.sessionTitleWriteVersionRef.current += 1 + const activeSessionId = ctx.session.activeSessionIdRef.current + if (activeSessionId) { + writes.discardTitleEditIntent(activeSessionId, ctx.session.savedTitleRef.current) + } + ctx.feedback.setBusyAction((current) => ( + current === 'save-title' || current === 'save-body' || current === 'undo-generation' ? null : current + )) + ctx.session.setSessionTitle(ctx.session.savedTitleRef.current) + const recoveryDecision = ctx.latestNoteGenerationUndoRef.current + if ( + recoveryDecision?.pendingRecoveryDecision + && recoveryDecision.generated + && recoveryDecision.entryId === ctx.session.noteEntryIdRef.current + ) { + writes.selectRecoveredSummaryChoice('generated') + const generatedDecision = ctx.latestNoteGenerationUndoRef.current! + const generated = recoveryDecision.generated + const canonicalGenerated = recoveryDecision.generatedCanonical ?? generated + ctx.session.savedBodyRef.current = serializeRichEditorDocument(canonicalGenerated) + writes.reserveNoteWrite(generated, recoveryDecision.entryId, ctx.session.activeSessionIdRef.current!) + ctx.session.setNoteBody(generated) + const finishDiscard = async (saved: boolean) => { + if (!saved) return false + markReferencedAttachmentsSaved(recoveryDecision.entryId, generated) + if (!await cleanupPendingAttachmentsNotIn(recoveryDecision.entryId, generated)) return false + if (ctx.latestNoteGenerationUndoRef.current === generatedDecision) { + ctx.generation.setLatestNoteGenerationUndo(null) + } + return true + } + if (serializeRichEditorDocument(generated) !== ctx.session.savedBodyRef.current) { + return writes.saveBody(generated).then(finishDiscard) + } + return finishDiscard(true) + } + writes.supersedeNoteWritesWithCurrentSavedBody() + const savedBody = parseRichEditorDocument(ctx.session.savedBodyRef.current) ?? emptyRichEditorDocument + ctx.session.setNoteBody(savedBody) + const entryId = ctx.session.noteEntryIdRef.current + return entryId ? cleanupPendingAttachmentsNotIn(entryId, savedBody) : true + } + + async function saveNoteNow(options: { manageBusy?: boolean } = {}): Promise { + const { manageBusy = true } = options + const title = ctx.session.sessionTitleRef.current + const titleDirty = Boolean(ctx.session.activeSession && title !== ctx.session.savedTitleRef.current) + let recoveryDecision = ctx.latestNoteGenerationUndoRef.current + const pendingRecoveryDecision = Boolean( + recoveryDecision?.pendingRecoveryDecision + && recoveryDecision.generated + && recoveryDecision.entryId === ctx.session.noteEntry?.id, + ) + const bodyDirty = pendingRecoveryDecision || Boolean( + ctx.session.noteEntry + && serializeRichEditorDocument(ctx.session.noteBodyRef.current) !== ctx.session.savedBodyRef.current, + ) + if (!titleDirty && !bodyDirty) return true + + let saved = true + if (titleDirty) { + saved = (await writes.saveTitle(title, { manageBusy })) && saved + } + if (bodyDirty && ctx.session.noteEntry) { + if (writes.noteSaveIsBlocked(ctx.session.noteEntry.sessionId)) return false + const entryId = ctx.session.noteEntry.id + const sessionId = ctx.session.noteEntry.sessionId + const visibleBodySerialized = serializeRichEditorDocument(ctx.session.noteBodyRef.current) + const sourceBody = pendingRecoveryDecision + ? recoveryDecision!.pendingRecoveryChoice === 'generated' + ? recoveryDecision!.generated! + : recoveryDecision!.before + : ctx.session.noteBodyRef.current + // Reserve the write before image imports start. Discard, navigation, or a + // newer save can now invalidate both materialization and the later save. + const writeVersion = ++ctx.session.noteBodyWriteVersionRef.current + writes.reserveNoteWrite(sourceBody, entryId, sessionId, { version: writeVersion }) + if (manageBusy) ctx.feedback.setBusyAction('save-body') + const releaseBusy = () => { + if (manageBusy && writeVersion === ctx.session.noteBodyWriteVersionRef.current) { + ctx.feedback.setBusyAction(null) + } + } + let body: RichEditorDocument + let importedAttachmentIds: string[] + try { + const materialized = await ctx.materializeInlineImages(sourceBody, { + entryId, + isCurrent: () => ( + writeVersion === ctx.session.noteBodyWriteVersionRef.current + && entryId === ctx.session.noteEntryIdRef.current + && sessionId === ctx.session.activeSessionIdRef.current + && serializeRichEditorDocument(ctx.session.noteBodyRef.current) === visibleBodySerialized + && (!pendingRecoveryDecision || ctx.latestNoteGenerationUndoRef.current === recoveryDecision) + ), + }) + body = materialized.document + importedAttachmentIds = materialized.importedAttachmentIds + } catch (cause) { + if (writeVersion !== ctx.session.noteBodyWriteVersionRef.current) return true + ctx.feedback.setError(formatError(cause)) + releaseBusy() + return false + } + if ( + writeVersion !== ctx.session.noteBodyWriteVersionRef.current + || entryId !== ctx.session.noteEntryIdRef.current + || sessionId !== ctx.session.activeSessionIdRef.current + || serializeRichEditorDocument(ctx.session.noteBodyRef.current) !== visibleBodySerialized + || (pendingRecoveryDecision && ctx.latestNoteGenerationUndoRef.current !== recoveryDecision) + ) { + if (!await ctx.cleanupMaterializedAttachments(importedAttachmentIds)) return false + releaseBusy() + return true + } + + // Materialization is pure with respect to Note state. Commit its result + // only after the reserved intent is still known to be current. + if (pendingRecoveryDecision) { + if (importedAttachmentIds.length > 0) { + recoveryDecision = { + ...recoveryDecision!, + [recoveryDecision!.pendingRecoveryChoice === 'generated' ? 'generated' : 'before']: body, + } + ctx.latestNoteGenerationUndoRef.current = recoveryDecision + ctx.generation.setLatestNoteGenerationUndo(recoveryDecision) + } + ctx.session.savedBodyRef.current = serializeRichEditorDocument( + recoveryDecision!.generatedCanonical ?? recoveryDecision!.generated!, + ) + } + rememberImportedAttachments(entryId, importedAttachmentIds) + ctx.session.setNoteBody(body) + const bodySaved = await writes.saveBody(body, { + manageBusy: false, + writeVersion, + entryId, + sessionId, + expectedCurrentBody: serializeRichEditorDocument(body), + }) + if (writeVersion !== ctx.session.noteBodyWriteVersionRef.current) { + const currentAttachmentIds = new Set( + managedAttachmentImagesInDocument(ctx.session.noteBodyRef.current) + .map((image) => image.attachmentId), + ) + const staleAttachmentIds = importedAttachmentIds.filter((id) => !currentAttachmentIds.has(id)) + if (!await ctx.cleanupMaterializedAttachments(staleAttachmentIds)) return false + } + let attachmentsCleaned = true + if (bodySaved) { + markReferencedAttachmentsSaved(entryId, body) + attachmentsCleaned = await cleanupPendingAttachmentsNotIn(entryId, body) + } + saved = bodySaved && attachmentsCleaned && saved + if ( + saved + && pendingRecoveryDecision + && writeVersion === ctx.session.noteBodyWriteVersionRef.current + && ctx.latestNoteGenerationUndoRef.current === recoveryDecision + && ctx.session.savedBodyRef.current === serializeRichEditorDocument(body) + ) ctx.generation.setLatestNoteGenerationUndo(null) + releaseBusy() + } + return saved + } + + return { + clearPendingImportedAttachments, + discardPendingSessionEdits, + hasPendingSessionEdits, + registerImportedNoteAttachment, + saveNoteNow, + savePendingSessionEdits, + } +} diff --git a/frontend/src/app/sessionActions.ts b/frontend/src/app/sessionActions.ts index 58c7b34..df55379 100644 --- a/frontend/src/app/sessionActions.ts +++ b/frontend/src/app/sessionActions.ts @@ -4,8 +4,6 @@ import { deleteSession, listSessions, openSessionNoteState, - updateEntry, - updateSession, type Session, } from '../tauri' import { @@ -16,10 +14,19 @@ import { type RichEditorDocument, } from '../editor/editorDocument' import { formatError, nextUntitledSessionTitle } from '../ui/format' -import type { DeletionWorkspace, GenerationWorkspace, RecordWorkspace, SessionWorkspace, WorkflowFeedback, WorkflowNavigation } from './types' +import { createSessionSaveActions } from './sessionActions.saving' +import { createSessionWriteActions } from './sessionActions.writes' +import type { + DeletionWorkspace, + GenerationWorkspace, + RecordWorkspace, + SessionWorkspace, + SummaryRecoveryCoordinator, + WorkflowFeedback, + WorkflowNavigation, +} from './types' import { useStableCapability } from './useStableCapability' - -const noteBodyMaxLength = 100_000 +import type { RecordLoadSuspension } from './useRecordHydration' export type SessionActionsContext = { session: SessionWorkspace @@ -32,73 +39,69 @@ export type SessionActionsContext = { | 'setTestwareDraftCount' | 'setFindingCount' > - generation: Pick + generation: Pick + latestNoteGenerationUndoRef: { current: GenerationWorkspace['latestNoteGenerationUndo'] } + summaryRecovery: SummaryRecoveryCoordinator feedback: WorkflowFeedback navigation: WorkflowNavigation deletion: Pick materializeInlineImages: ( document: RichEditorDocument, - options?: { entryId?: string | null; updateNoteBody?: boolean }, - ) => Promise - invalidateRecordLoads: () => void + options?: { entryId?: string | null; isCurrent?: () => boolean }, + ) => Promise<{ document: RichEditorDocument; importedAttachmentIds: string[] }> + cleanupMaterializedAttachments: (attachmentIds: string[]) => Promise + suspendRecordLoads: () => RecordLoadSuspension + restoreRecordLoads: (suspension: RecordLoadSuspension) => Promise resetRecordHydration: () => void saveDirtyRecordsNow: () => Promise + retryPendingRecordCompensations: (sessionId: string) => Promise } export function createSessionActions(ctx: SessionActionsContext) { - async function savePendingSessionEdits(): Promise { - if (ctx.session.forcedPendingSaveRef.current) return ctx.session.forcedPendingSaveRef.current - - const pending = flushPendingSessionEdits().finally(() => { - if (ctx.session.forcedPendingSaveRef.current === pending) { - ctx.session.forcedPendingSaveRef.current = null - } - }) - ctx.session.forcedPendingSaveRef.current = pending - return pending - } - - async function flushPendingSessionEdits(): Promise { - ctx.session.suppressAmbientNoteSaveRef.current = true - try { - do { - const flushedNote = await saveNoteNow({ manageBusy: false }) - if (!flushedNote) return false - const flushedRecords = await ctx.saveDirtyRecordsNow() - if (!flushedRecords) return false - } while (hasPendingSessionEdits()) - return true - } finally { - ctx.session.suppressAmbientNoteSaveRef.current = false - } - } + const writes = createSessionWriteActions(ctx) + const saving = createSessionSaveActions(ctx, writes) - function hasPendingSessionEdits(): boolean { - const title = ctx.session.sessionTitleRef.current.trim() - const titleDirty = Boolean(ctx.session.activeSession && title && title !== ctx.session.savedTitleRef.current) - const bodyDirty = Boolean( - ctx.session.noteEntry - && serializeRichEditorDocument(ctx.session.noteBodyRef.current) !== ctx.session.savedBodyRef.current, - ) - return titleDirty - || bodyDirty - || ctx.records.dirtyDraftIdsRef.current.size > 0 - || ctx.records.dirtyFindingIdsRef.current.size > 0 - } - - async function openSession(session: Session, showNotice = true, onOpened?: () => void) { + async function openSession( + session: Session, + showNotice = true, + onOpened?: () => void, + requestedEpoch?: number, + ) { + const navigationEpoch = requestedEpoch ?? writes.beginSessionNavigation() + if (!writes.sessionNavigationIsCurrent(navigationEpoch)) return + if (!writes.recoveryDiscoveryAllowsNoteHydration()) return + let loadSuspension: RecordLoadSuspension | null = null + let hydrationCommitted = false try { ctx.feedback.setBusyAction('open-session') ctx.feedback.setError(null) - const flushed = await savePendingSessionEdits() - if (!flushed) return - ctx.invalidateRecordLoads() + const flushed = await saving.savePendingSessionEdits() + if (!flushed || !writes.sessionNavigationIsCurrent(navigationEpoch)) return + const compensatedTitle = await writes.retryPendingTitleCompensation(session.id) + if (!compensatedTitle || !writes.sessionNavigationIsCurrent(navigationEpoch)) return + const compensatedNote = await writes.retryPendingNoteCompensation(session.id) + if (!compensatedNote || !writes.sessionNavigationIsCurrent(navigationEpoch)) return + const compensatedRecords = await ctx.retryPendingRecordCompensations(session.id) + if (!compensatedRecords || !writes.sessionNavigationIsCurrent(navigationEpoch)) return + loadSuspension = ctx.suspendRecordLoads() + ctx.summaryRecovery.openingSessionIdRef.current = session.id const opened = await openSessionNoteState(session.id) - const { session: reopened, noteEntry: editableNote } = opened + if (!writes.sessionNavigationIsCurrent(navigationEpoch)) return + const { session: reopened } = opened + const recoveredSummaryEntry = ctx.summaryRecovery.completedSummaryEntriesRef.current.get(session.id) + if (recoveredSummaryEntry && recoveredSummaryEntry.id !== opened.noteEntry.id) { + ctx.feedback.setError('Recovered Summary returned an unexpected Note Entry.') + return + } + const editableNote = recoveredSummaryEntry ?? opened.noteEntry + if (recoveredSummaryEntry) ctx.summaryRecovery.completedSummaryEntriesRef.current.delete(session.id) ctx.session.sessionTitleWriteVersionRef.current += 1 ctx.session.noteBodyWriteVersionRef.current += 1 ctx.resetRecordHydration() + hydrationCommitted = true + ctx.session.activeSessionIdRef.current = reopened.id + ctx.session.noteEntryIdRef.current = editableNote.id ctx.session.setActiveSession(reopened) ctx.session.setNoteEntry(editableNote) ctx.generation.setLatestNoteGenerationUndo(null) @@ -111,25 +114,44 @@ export function createSessionActions(ctx: SessionActionsContext) { ctx.session.setNoteBody(noteDocument) ctx.session.savedTitleRef.current = reopened.title ctx.session.savedBodyRef.current = serializeRichEditorDocument(noteDocument) + writes.resetTitleIntent(reopened.id, reopened.title) ctx.navigation.setActiveView('sessions') if (showNotice) ctx.feedback.setNotice(`Opened ${reopened.title}`) onOpened?.() } catch (cause) { - ctx.feedback.setError(formatError(cause)) + if (writes.sessionNavigationIsCurrent(navigationEpoch)) ctx.feedback.setError(formatError(cause)) } finally { - ctx.feedback.setBusyAction(null) + if (!hydrationCommitted && loadSuspension) { + await ctx.restoreRecordLoads(loadSuspension) + } + if (writes.sessionNavigationIsCurrent(navigationEpoch)) { + if (ctx.summaryRecovery.openingSessionIdRef.current === session.id) { + ctx.summaryRecovery.openingSessionIdRef.current = null + } + ctx.feedback.setBusyAction(null) + } } } async function handleNewSession() { + const navigationEpoch = writes.beginSessionNavigation() + if (!writes.recoveryDiscoveryAllowsNoteHydration()) return + let loadSuspension: RecordLoadSuspension | null = null + let hydrationCommitted = false try { ctx.feedback.setBusyAction('new-session') ctx.feedback.setError(null) - const flushed = await savePendingSessionEdits() - if (!flushed) return - ctx.invalidateRecordLoads() + const flushed = await saving.savePendingSessionEdits() + if (!flushed || !writes.sessionNavigationIsCurrent(navigationEpoch)) return + loadSuspension = ctx.suspendRecordLoads() const title = nextUntitledSessionTitle(ctx.session.sessions) const session = await createSession({ title, sessionContext: null, objectiveNotes: null }) + // Session creation is durable even if the follow-up Note creation fails. + // Publish it immediately so supersession cannot hide a partial result; + // opening it later will repair a missing Note through openSessionNoteState. + ctx.session.setSessions((previous) => mergeSessions(previous, [session])) + writes.initializeTitleIntent(session.id, session.title) + if (!writes.sessionNavigationIsCurrent(navigationEpoch)) return const editableNote = await createEntry({ sessionId: session.id, entryType: 'note', @@ -138,11 +160,16 @@ export function createSessionActions(ctx: SessionActionsContext) { metadataJson: null, excludedFromGeneration: false, }) + if (!writes.sessionNavigationIsCurrent(navigationEpoch)) return const nextSessions = await listSessions() + if (!writes.sessionNavigationIsCurrent(navigationEpoch)) return ctx.resetRecordHydration() + hydrationCommitted = true ctx.session.sessionTitleWriteVersionRef.current += 1 ctx.session.noteBodyWriteVersionRef.current += 1 - ctx.session.setSessions(nextSessions) + ctx.session.activeSessionIdRef.current = session.id + ctx.session.noteEntryIdRef.current = editableNote.id + ctx.session.setSessions((previous) => mergeSessions(mergeSessions(nextSessions, [session]), previous)) ctx.session.setActiveSession(session) ctx.session.setNoteEntry(editableNote) ctx.generation.setLatestNoteGenerationUndo(null) @@ -157,18 +184,27 @@ export function createSessionActions(ctx: SessionActionsContext) { ctx.navigation.setActiveView('sessions') ctx.feedback.setNotice('New Session created') } catch (cause) { - ctx.feedback.setError(formatError(cause)) + if (writes.sessionNavigationIsCurrent(navigationEpoch)) ctx.feedback.setError(formatError(cause)) } finally { - ctx.feedback.setBusyAction(null) + if (!hydrationCommitted && loadSuspension) { + await ctx.restoreRecordLoads(loadSuspension) + } + if (writes.sessionNavigationIsCurrent(navigationEpoch)) ctx.feedback.setBusyAction(null) } } function clearActiveSessionState() { + const clearedSessionId = ctx.session.activeSessionIdRef.current + const clearedEntryId = ctx.session.noteEntryIdRef.current + if (clearedSessionId) writes.clearTitleIntent(clearedSessionId) + if (clearedEntryId) saving.clearPendingImportedAttachments(clearedEntryId) ctx.resetRecordHydration() ctx.records.dirtyDraftIdsRef.current.clear() ctx.records.dirtyFindingIdsRef.current.clear() ctx.session.sessionTitleWriteVersionRef.current += 1 ctx.session.noteBodyWriteVersionRef.current += 1 + ctx.session.activeSessionIdRef.current = null + ctx.session.noteEntryIdRef.current = null ctx.session.setActiveSession(null) ctx.session.setNoteEntry(null) ctx.generation.setLatestNoteGenerationUndo(null) @@ -193,6 +229,7 @@ export function createSessionActions(ctx: SessionActionsContext) { ctx.feedback.setBusyAction('delete-session') ctx.feedback.setError(null) await deleteSession(sessionToDelete.id) + ctx.session.setSessions((previous) => previous.filter((session) => session.id !== sessionToDelete.id)) // Clear active-Session state immediately after the delete succeeds, before any // follow-up call that could reject. Once cleared, the title/body autosave // effects have nothing left to save against the deleted session, so the @@ -216,86 +253,40 @@ export function createSessionActions(ctx: SessionActionsContext) { } } - async function saveTitle(title: string, options: { manageBusy?: boolean } = {}): Promise { - const { manageBusy = true } = options - if (!ctx.session.activeSession || ctx.session.deletingSessionIdRef.current === ctx.session.activeSession.id) return false - const sessionId = ctx.session.activeSession.id - const writeVersion = ++ctx.session.sessionTitleWriteVersionRef.current - try { - if (manageBusy) ctx.feedback.setBusyAction('save-title') - const saved = await updateSession(sessionId, { title }) - if (writeVersion !== ctx.session.sessionTitleWriteVersionRef.current || ctx.session.activeSessionIdRef.current !== saved.id) return true - ctx.session.savedTitleRef.current = saved.title - ctx.session.setActiveSession(saved) - ctx.session.setSessions((previous) => previous.map((session) => (session.id === saved.id ? saved : session))) - ctx.feedback.setNotice('Session saved') - return true - } catch (cause) { - if (writeVersion !== ctx.session.sessionTitleWriteVersionRef.current) return true - ctx.feedback.setError(formatError(cause)) - return false - } finally { - if (manageBusy && writeVersion === ctx.session.sessionTitleWriteVersionRef.current) ctx.feedback.setBusyAction(null) - } - } - - async function saveBody(body: RichEditorDocument, options: { manageBusy?: boolean } = {}): Promise { - const { manageBusy = true } = options - if (!ctx.session.noteEntry || ctx.session.deletingSessionIdRef.current === ctx.session.noteEntry.sessionId) return false - const storedBody = richEditorDocumentToStoredBody(body) - if (storedBody.body.length > noteBodyMaxLength) { - ctx.feedback.setError('Note is too large to autosave. This usually means an image was embedded directly in the note; paste images again so QA Scribe can store them as attachments.') - return false - } - const writeVersion = ++ctx.session.noteBodyWriteVersionRef.current - try { - if (manageBusy) ctx.feedback.setBusyAction('save-body') - const saved = await updateEntry(ctx.session.noteEntry.id, storedBody) - if (writeVersion !== ctx.session.noteBodyWriteVersionRef.current) return true - ctx.session.savedBodyRef.current = serializeRichEditorDocument(richEditorDocumentFromStoredBody(saved)) - ctx.session.setNoteEntry(saved) - ctx.feedback.setNotice('Note saved') - return true - } catch (cause) { - if (writeVersion !== ctx.session.noteBodyWriteVersionRef.current) return true - ctx.feedback.setError(formatError(cause)) - return false - } finally { - if (manageBusy && writeVersion === ctx.session.noteBodyWriteVersionRef.current) ctx.feedback.setBusyAction(null) - } - } - - async function saveNoteNow(options: { manageBusy?: boolean } = {}): Promise { - const { manageBusy = true } = options - const title = ctx.session.sessionTitleRef.current.trim() - let body: RichEditorDocument - try { - body = await ctx.materializeInlineImages(ctx.session.noteBodyRef.current, { entryId: ctx.session.noteEntry?.id, updateNoteBody: true }) - } catch (cause) { - ctx.feedback.setError(formatError(cause)) - return false - } - let saved = true - if (ctx.session.activeSession && title && title !== ctx.session.savedTitleRef.current) { - saved = (await saveTitle(title, { manageBusy })) && saved - } - if (ctx.session.noteEntry && serializeRichEditorDocument(body) !== ctx.session.savedBodyRef.current) { - saved = (await saveBody(body, { manageBusy })) && saved - } - return saved - } - return { + adoptCanonicalNoteBody: writes.adoptCanonicalNoteBody, + beginSessionNavigation: writes.beginSessionNavigation, clearActiveSessionState, + discardPendingSessionEdits: saving.discardPendingSessionEdits, handleDeleteSession, handleNewSession, + hasPendingSessionCompensations: writes.hasPendingSessionCompensations, + hasPendingSessionEdits: saving.hasPendingSessionEdits, openSession, + registerImportedNoteAttachment: saving.registerImportedNoteAttachment, + registerNoteEditIntent: writes.registerNoteEditIntent, + registerRecordEditIntent: writes.registerRecordEditIntent, + registerTitleEditIntent: writes.registerTitleEditIntent, + retryAllPendingSessionCompensations: writes.retryAllPendingSessionCompensations, + selectRecoveredSummaryChoice: writes.selectRecoveredSummaryChoice, requestDeleteSession, - saveBody, - saveNoteNow, - savePendingSessionEdits, - saveTitle, + saveBody: writes.saveBody, + saveNoteNow: saving.saveNoteNow, + savePendingSessionEdits: saving.savePendingSessionEdits, + saveTitle: writes.saveTitle, + sessionNavigationIsCurrent: writes.sessionNavigationIsCurrent, + waitForPendingSessionWrites: writes.waitForPendingSessionWrites, + } +} + +function mergeSessions(current: Session[], incoming: Session[]): Session[] { + const merged = [...current] + for (const session of incoming) { + const index = merged.findIndex((candidate) => candidate.id === session.id) + if (index === -1) merged.push(session) + else merged[index] = session } + return merged } export function useSessionActions(ctx: SessionActionsContext) { diff --git a/frontend/src/app/sessionActions.writes.ts b/frontend/src/app/sessionActions.writes.ts new file mode 100644 index 0000000..a708d42 --- /dev/null +++ b/frontend/src/app/sessionActions.writes.ts @@ -0,0 +1,450 @@ +import { updateEntry, updateSession, type Session } from '../tauri' +import { + emptyRichEditorDocument, + parseRichEditorDocument, + richEditorDocumentFromStoredBody, + richEditorDocumentToStoredBody, + serializeRichEditorDocument, + type RichEditorDocument, +} from '../editor/editorDocument' +import { formatError } from '../ui/format' +import type { SessionActionsContext } from './sessionActions' + +const noteBodyMaxLength = 100_000 + +type NoteWriteIntent = { + version: number + entryId: string + sessionId: string + body?: RichEditorDocument + kind: 'write' | 'editor' + expectedCurrentBody?: string + compensationPending?: boolean +} + +type TitleIntent = { + revision: number + baselineTitle: string + backendTitle: string + compensationPending: boolean +} + +export function createSessionWriteActions(ctx: SessionActionsContext) { + let sessionNavigationEpoch = 0 + const noteWriteIntents = new Map() + const pendingWriteOperations = new Set>() + const titleIntents = new Map() + + function trackWrite(operation: Promise): Promise { + const tracked = operation.finally(() => pendingWriteOperations.delete(tracked)) + pendingWriteOperations.add(tracked) + return tracked + } + + async function waitForPendingSessionWrites(): Promise { + while (pendingWriteOperations.size > 0) { + await Promise.allSettled(Array.from(pendingWriteOperations)) + } + } + + function titleIntentFor(sessionId: string, fallbackTitle: string): TitleIntent { + const current = titleIntents.get(sessionId) + if (current) return current + const created = { revision: 0, baselineTitle: fallbackTitle, backendTitle: fallbackTitle, compensationPending: false } + titleIntents.set(sessionId, created) + return created + } + + function initializeTitleIntent(sessionId: string, title: string) { + titleIntentFor(sessionId, title) + } + + function resetTitleIntent(sessionId: string, title: string) { + const intent = titleIntentFor(sessionId, title) + intent.baselineTitle = title + intent.backendTitle = title + intent.compensationPending = false + } + + function clearTitleIntent(sessionId: string) { + const intent = titleIntents.get(sessionId) + if (intent) intent.revision += 1 + titleIntents.delete(sessionId) + } + + function discardTitleEditIntent(sessionId: string, baselineTitle: string) { + const intent = titleIntentFor(sessionId, baselineTitle) + intent.revision += 1 + intent.baselineTitle = baselineTitle + } + + function publishTitleBaseline(sessionId: string, saved: Session, updateEditorIfMatching?: string) { + ctx.session.setSessions((previous) => previous.map((session) => (session.id === saved.id ? saved : session))) + if (ctx.session.activeSessionIdRef.current !== sessionId) return + ctx.session.savedTitleRef.current = saved.title + if (updateEditorIfMatching === undefined || ctx.session.sessionTitleRef.current === updateEditorIfMatching) { + ctx.session.setSessionTitle(saved.title) + } + ctx.session.setActiveSession(saved) + } + + async function reconcileSavedTitleAfterStaleWrite(sessionId: string, staleTitle: string): Promise { + const intent = titleIntents.get(sessionId) + if (!intent) return true + intent.backendTitle = staleTitle + if (intent.backendTitle === intent.baselineTitle) { + intent.compensationPending = false + return true + } + intent.compensationPending = true + const restoredTitle = intent.baselineTitle + try { + const restored = await trackWrite(updateSession(sessionId, { title: restoredTitle })) + intent.backendTitle = restored.title + intent.compensationPending = intent.backendTitle !== intent.baselineTitle + publishTitleBaseline(sessionId, restored, restoredTitle) + if (intent.compensationPending) return reconcileSavedTitleAfterStaleWrite(sessionId, restored.title) + return true + } catch (cause) { + intent.compensationPending = true + const staleSession = ctx.session.sessions.find((session) => session.id === sessionId) + if (staleSession) { + ctx.session.setSessions((previous) => previous.map((session) => ( + session.id === sessionId ? { ...session, title: staleTitle } : session + ))) + } + if (ctx.session.activeSessionIdRef.current === sessionId) { + ctx.session.savedTitleRef.current = staleTitle + ctx.session.setActiveSession((current) => current?.id === sessionId ? { ...current, title: staleTitle } : current) + } + ctx.feedback.setError(formatError(cause)) + return false + } + } + + async function retryPendingTitleCompensation(sessionId: string): Promise { + const intent = titleIntents.get(sessionId) + if (!intent?.compensationPending) return true + return reconcileSavedTitleAfterStaleWrite(sessionId, intent.backendTitle) + } + + function reserveNoteWrite( + body: RichEditorDocument, + entryId: string, + sessionId: string, + options: { version?: number; expectedCurrentBody?: string } = {}, + ): NoteWriteIntent { + const version = options.version ?? ++ctx.session.noteBodyWriteVersionRef.current + const intent: NoteWriteIntent = { version, entryId, sessionId, body, kind: 'write', expectedCurrentBody: options.expectedCurrentBody } + noteWriteIntents.set(entryId, intent) + return intent + } + + function supersedeNoteWritesWithCurrentSavedBody() { + const entryId = ctx.session.noteEntryIdRef.current + const sessionId = ctx.session.activeSessionIdRef.current + if (!entryId || !sessionId) { + ctx.session.noteBodyWriteVersionRef.current += 1 + return + } + const body = parseRichEditorDocument(ctx.session.savedBodyRef.current) ?? emptyRichEditorDocument + reserveNoteWrite(body, entryId, sessionId) + } + + function adoptCanonicalNoteBody(body: RichEditorDocument, entryId: string, sessionId: string) { + reserveNoteWrite(body, entryId, sessionId) + } + + function invalidateSessionNavigationForEdit() { + sessionNavigationEpoch += 1 + ctx.summaryRecovery.openingSessionIdRef.current = null + ctx.feedback.setBusyAction((current) => ( + current === 'open-session' + || current === 'new-session' + || current === 'save-title' + || current === 'save-body' + ? null + : current + )) + } + + function registerTitleEditIntent() { + const sessionId = ctx.session.activeSessionIdRef.current + if (sessionId) { + const intent = titleIntentFor(sessionId, ctx.session.savedTitleRef.current) + intent.revision += 1 + ctx.session.sessionTitleWriteVersionRef.current += 1 + } + invalidateSessionNavigationForEdit() + } + + function registerNoteEditIntent(_body: RichEditorDocument) { + const version = ++ctx.session.noteBodyWriteVersionRef.current + const entryId = ctx.session.noteEntryIdRef.current + const sessionId = ctx.session.activeSessionIdRef.current + if (entryId && sessionId) { + noteWriteIntents.set(entryId, { version, entryId, sessionId, kind: 'editor' }) + } + // `body` is intentionally not made a compensating write. It may still + // contain inline images, or represent the generated side of a recovered + // Summary decision rather than the authored body that Save will choose. + invalidateSessionNavigationForEdit() + } + + function registerRecordEditIntent() { + invalidateSessionNavigationForEdit() + } + + function selectRecoveredSummaryChoice(choice: 'authored' | 'generated') { + const decision = ctx.latestNoteGenerationUndoRef.current + if (!decision?.pendingRecoveryDecision) return + const next = { ...decision, pendingRecoveryChoice: choice } + ctx.latestNoteGenerationUndoRef.current = next + ctx.generation.setLatestNoteGenerationUndo(next) + } + + async function reconcileNoteAfterStaleWrite(entryId: string, staleEntry: Awaited>): Promise { + const intent = noteWriteIntents.get(entryId) + if (!intent) return true + if (intent.kind === 'editor' || !intent.body) { + if ( + ctx.session.activeSessionIdRef.current === intent.sessionId + && ctx.session.noteEntryIdRef.current === entryId + ) { + ctx.session.setNoteEntry(staleEntry) + ctx.session.savedBodyRef.current = serializeRichEditorDocument(richEditorDocumentFromStoredBody(staleEntry)) + } + return true + } + intent.compensationPending = true + try { + const restored = await trackWrite(updateEntry(entryId, richEditorDocumentToStoredBody(intent.body))) + if (noteWriteIntents.get(entryId) !== intent) return reconcileNoteAfterStaleWrite(entryId, restored) + intent.compensationPending = false + if ( + ctx.session.activeSessionIdRef.current === intent.sessionId + && ctx.session.noteEntryIdRef.current === entryId + ) { + ctx.session.setNoteEntry(restored) + ctx.session.savedBodyRef.current = serializeRichEditorDocument(richEditorDocumentFromStoredBody(restored)) + } + return true + } catch (cause) { + if (noteWriteIntents.get(entryId) !== intent) return reconcileNoteAfterStaleWrite(entryId, staleEntry) + intent.compensationPending = true + if ( + ctx.session.activeSessionIdRef.current === intent.sessionId + && ctx.session.noteEntryIdRef.current === entryId + ) { + // Keep the desired body visible, but baseline it against the stale + // value known to have landed. This restores dirty state and schedules + // an ordinary autosave retry rather than hiding a failed compensation. + ctx.session.savedBodyRef.current = serializeRichEditorDocument(richEditorDocumentFromStoredBody(staleEntry)) + ctx.session.setNoteEntry(staleEntry) + ctx.session.setNoteBody(intent.body) + } + ctx.feedback.setError(formatError(cause)) + return false + } + } + + async function retryPendingNoteCompensation(sessionId: string): Promise { + for (const intent of noteWriteIntents.values()) { + if (intent.sessionId !== sessionId || !intent.compensationPending) continue + if (!intent.body) return false + try { + const restored = await trackWrite(updateEntry(intent.entryId, richEditorDocumentToStoredBody(intent.body))) + if (noteWriteIntents.get(intent.entryId) !== intent) { + if (!await retryPendingNoteCompensation(sessionId)) return false + continue + } + intent.compensationPending = false + if ( + ctx.session.activeSessionIdRef.current === sessionId + && ctx.session.noteEntryIdRef.current === intent.entryId + ) { + ctx.session.setNoteEntry(restored) + ctx.session.savedBodyRef.current = serializeRichEditorDocument(intent.body) + } + } catch (cause) { + intent.compensationPending = true + ctx.feedback.setError(formatError(cause)) + return false + } + } + return true + } + + function hasPendingSessionCompensations(): boolean { + return pendingWriteOperations.size > 0 + || Array.from(titleIntents.values()).some((intent) => intent.compensationPending) + || Array.from(noteWriteIntents.values()).some((intent) => intent.compensationPending) + } + + async function retryAllPendingSessionCompensations(): Promise { + let reconciled = true + for (const [sessionId, intent] of titleIntents) { + if (intent.compensationPending && !await retryPendingTitleCompensation(sessionId)) reconciled = false + } + const pendingNoteSessionIds = new Set( + Array.from(noteWriteIntents.values()) + .filter((intent) => intent.compensationPending) + .map((intent) => intent.sessionId), + ) + for (const sessionId of pendingNoteSessionIds) { + if (!await retryPendingNoteCompensation(sessionId)) reconciled = false + } + return reconciled + } + + function beginSessionNavigation(): number { + ctx.summaryRecovery.openingSessionIdRef.current = null + sessionNavigationEpoch += 1 + ctx.feedback.setBusyAction((current) => ( + current === 'open-session' || current === 'new-session' ? null : current + )) + return sessionNavigationEpoch + } + + function sessionNavigationIsCurrent(epoch: number): boolean { + return epoch === sessionNavigationEpoch + } + + function summaryRecoveryBlocksNoteSave(sessionId: string): boolean { + return ctx.summaryRecovery.discoveryPendingRef.current + || Array.from(ctx.summaryRecovery.unresolvedSummaryJobsRef.current.values()).includes(sessionId) + } + + function noteSaveIsBlocked(sessionId: string): boolean { + if (!summaryRecoveryBlocksNoteSave(sessionId)) return false + ctx.summaryRecovery.blockedSaveSessionIdsRef.current.add(sessionId) + return true + } + + function recoveryDiscoveryAllowsNoteHydration(): boolean { + if (!ctx.summaryRecovery.discoveryPendingRef.current) return true + ctx.feedback.setError('Session recovery status is unavailable. Restart QA Scribe before opening an editable Note.') + return false + } + + async function saveTitle(title: string, options: { manageBusy?: boolean } = {}): Promise { + const { manageBusy = true } = options + if (!ctx.session.activeSession || ctx.session.deletingSessionIdRef.current === ctx.session.activeSession.id) return false + const normalizedTitle = title.trim() + if (!normalizedTitle) { + ctx.feedback.setError('Session title is required.') + return false + } + const sessionId = ctx.session.activeSession.id + const titleIntent = titleIntentFor(sessionId, ctx.session.savedTitleRef.current) + const intentRevision = ++titleIntent.revision + const writeVersion = ++ctx.session.sessionTitleWriteVersionRef.current + try { + if (manageBusy) ctx.feedback.setBusyAction('save-title') + const saved = await trackWrite(updateSession(sessionId, { title: normalizedTitle })) + titleIntent.backendTitle = saved.title + if (intentRevision !== titleIntent.revision) { + return reconcileSavedTitleAfterStaleWrite(sessionId, saved.title) + } + titleIntent.baselineTitle = saved.title + titleIntent.compensationPending = false + publishTitleBaseline(sessionId, saved, title) + ctx.feedback.setNotice('Session saved') + return true + } catch (cause) { + if (intentRevision !== titleIntent.revision) return true + ctx.feedback.setError(formatError(cause)) + return false + } finally { + if (manageBusy && writeVersion === ctx.session.sessionTitleWriteVersionRef.current) ctx.feedback.setBusyAction(null) + } + } + + async function saveBody( + body: RichEditorDocument, + options: { + manageBusy?: boolean + writeVersion?: number + entryId?: string + sessionId?: string + expectedCurrentBody?: string + allowRecoveryWrite?: boolean + } = {}, + ): Promise { + const { manageBusy = true } = options + if (!ctx.session.noteEntry || ctx.session.deletingSessionIdRef.current === ctx.session.noteEntry.sessionId) return false + if (!options.allowRecoveryWrite && noteSaveIsBlocked(ctx.session.noteEntry.sessionId)) return false + const storedBody = richEditorDocumentToStoredBody(body) + if (storedBody.body.length > noteBodyMaxLength) { + ctx.feedback.setError('Note is too large to autosave. This usually means an image was embedded directly in the note; paste images again so QA Scribe can store them as attachments.') + return false + } + const entryId = options.entryId ?? ctx.session.noteEntry.id + const sessionId = options.sessionId ?? ctx.session.noteEntry.sessionId + const intent = reserveNoteWrite(body, entryId, sessionId, { + version: options.writeVersion, + expectedCurrentBody: options.expectedCurrentBody, + }) + const writeVersion = intent.version + if ( + writeVersion !== ctx.session.noteBodyWriteVersionRef.current + || entryId !== ctx.session.noteEntryIdRef.current + || sessionId !== ctx.session.activeSessionIdRef.current + ) return true + try { + if (manageBusy) ctx.feedback.setBusyAction('save-body') + const saved = await trackWrite(updateEntry(entryId, storedBody)) + if ( + writeVersion !== ctx.session.noteBodyWriteVersionRef.current + || noteWriteIntents.get(entryId) !== intent + ) return reconcileNoteAfterStaleWrite(entryId, saved) + if ( + entryId !== ctx.session.noteEntryIdRef.current + || sessionId !== ctx.session.activeSessionIdRef.current + || saved.id !== entryId + || saved.sessionId !== sessionId + || ( + intent.expectedCurrentBody !== undefined + && serializeRichEditorDocument(ctx.session.noteBodyRef.current) !== intent.expectedCurrentBody + ) + ) return true + ctx.session.savedBodyRef.current = serializeRichEditorDocument(richEditorDocumentFromStoredBody(saved)) + ctx.session.setNoteEntry(saved) + ctx.feedback.setNotice('Note saved') + return true + } catch (cause) { + if (writeVersion !== ctx.session.noteBodyWriteVersionRef.current || noteWriteIntents.get(entryId) !== intent) return true + ctx.feedback.setError(formatError(cause)) + return false + } finally { + if (manageBusy && writeVersion === ctx.session.noteBodyWriteVersionRef.current) ctx.feedback.setBusyAction(null) + } + } + + return { + adoptCanonicalNoteBody, + beginSessionNavigation, + clearTitleIntent, + discardTitleEditIntent, + hasPendingSessionCompensations, + initializeTitleIntent, + noteSaveIsBlocked, + recoveryDiscoveryAllowsNoteHydration, + registerNoteEditIntent, + registerRecordEditIntent, + registerTitleEditIntent, + reserveNoteWrite, + resetTitleIntent, + retryAllPendingSessionCompensations, + retryPendingNoteCompensation, + retryPendingTitleCompensation, + saveBody, + saveTitle, + selectRecoveredSummaryChoice, + sessionNavigationIsCurrent, + supersedeNoteWritesWithCurrentSavedBody, + waitForPendingSessionWrites, + } +} + +export type SessionWriteActions = ReturnType diff --git a/frontend/src/app/sessionNavigationActions.ts b/frontend/src/app/sessionNavigationActions.ts new file mode 100644 index 0000000..8dbf159 --- /dev/null +++ b/frontend/src/app/sessionNavigationActions.ts @@ -0,0 +1,98 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react' +import { reopenSession, type Session } from '../tauri' +import { formatError } from '../ui/format' +import type { MainView } from '../ui/types' +import type { AppNavigationRoute } from './navigationRoute' + +type SessionOpenActions = { + beginSessionNavigation: () => number + sessionNavigationIsCurrent: (epoch: number) => boolean + openSession: ( + session: Session, + showNotice?: boolean, + onOpened?: () => void, + requestedEpoch?: number, + ) => Promise +} + +type SessionNavigationContext = { + activeSessionId: string | null + activeView: MainView + sessions: Session[] + settingsReturnViewRef: MutableRefObject + sessionActions: SessionOpenActions + requestActiveView: (view: MainView) => void + requestSessionNavigation: (view: MainView, navigate: () => Promise) => Promise + setActiveView: Dispatch> + setError: Dispatch> + setFocusedRecordId: Dispatch> + setPendingSettingsSection: Dispatch> + setSettingsSection: Dispatch> +} + +export function createSessionNavigationActions(ctx: SessionNavigationContext) { + function openSessionInCurrentView(session: Session) { + const destination = ctx.activeView === 'testware' || ctx.activeView === 'findings' + ? ctx.activeView + : 'sessions' + if (ctx.activeSessionId === session.id) { + ctx.sessionActions.beginSessionNavigation() + return Promise.resolve() + } + return ctx.requestSessionNavigation(destination, () => ctx.sessionActions.openSession(session, true, () => ctx.setActiveView(destination))) + } + + async function openLibraryRecord(sessionId: string, view: 'testware' | 'findings', recordId: string) { + await ctx.requestSessionNavigation(view, async () => { + const epoch = ctx.sessionActions.beginSessionNavigation() + try { + const session = ctx.sessions.find((candidate) => candidate.id === sessionId) ?? await reopenSession(sessionId) + if (!ctx.sessionActions.sessionNavigationIsCurrent(epoch)) return + await ctx.sessionActions.openSession(session, false, () => { + ctx.setFocusedRecordId(recordId) + ctx.setActiveView(view) + }, epoch) + } catch (cause) { + if (ctx.sessionActions.sessionNavigationIsCurrent(epoch)) { + ctx.setError(`Could not open the selected output. ${formatError(cause)}`) + } + } + }) + } + + async function applyNavigationRoute(route: AppNavigationRoute) { + if (route.kind === 'settings') { + if (ctx.activeView !== 'settings') ctx.settingsReturnViewRef.current = ctx.activeView + ctx.setSettingsSection(route.sectionId) + ctx.setPendingSettingsSection(route.sectionId) + ctx.requestActiveView('settings') + return + } + if (route.kind === 'library') { + ctx.requestActiveView(route.view) + return + } + if (!route.sessionId || ctx.activeSessionId === route.sessionId) { + ctx.setFocusedRecordId(route.recordId) + ctx.requestActiveView(route.view) + return + } + await ctx.requestSessionNavigation(route.view, async () => { + const epoch = ctx.sessionActions.beginSessionNavigation() + try { + const session = ctx.sessions.find((candidate) => candidate.id === route.sessionId) ?? await reopenSession(route.sessionId!) + if (!ctx.sessionActions.sessionNavigationIsCurrent(epoch)) return + await ctx.sessionActions.openSession(session, false, () => { + ctx.setFocusedRecordId(route.recordId) + ctx.setActiveView(route.view) + }, epoch) + } catch (cause) { + if (ctx.sessionActions.sessionNavigationIsCurrent(epoch)) { + ctx.setError(`Could not open the linked workspace. ${formatError(cause)}`) + } + } + }) + } + + return { applyNavigationRoute, openLibraryRecord, openSessionInCurrentView } +} diff --git a/frontend/src/app/types.ts b/frontend/src/app/types.ts index a5372e8..58bcbd6 100644 --- a/frontend/src/app/types.ts +++ b/frontend/src/app/types.ts @@ -13,6 +13,10 @@ export type CopiedTarget = export type LatestNoteGenerationUndo = { entryId: string before: RichEditorDocument + pendingRecoveryDecision?: boolean + generated?: RichEditorDocument + generatedCanonical?: RichEditorDocument + pendingRecoveryChoice?: 'authored' | 'generated' } export type RichRecordPatch = Partial> @@ -64,6 +68,15 @@ export type GenerationWorkspace = { setLatestNoteGenerationUndo: Dispatch> } +export type SummaryRecoveryCoordinator = { + recoveredJobsRef: MutableRefObject> + unresolvedSummaryJobsRef: MutableRefObject> + completedSummaryEntriesRef: MutableRefObject> + openingSessionIdRef: MutableRefObject + discoveryPendingRef: MutableRefObject + blockedSaveSessionIdsRef: MutableRefObject> +} + export type AiSelection = { selectedProvider: AiProvider selectedModel: string diff --git a/frontend/src/app/useAppController.autosave.note-writes.test.ts b/frontend/src/app/useAppController.autosave.note-writes.test.ts new file mode 100644 index 0000000..6c6e7f5 --- /dev/null +++ b/frontend/src/app/useAppController.autosave.note-writes.test.ts @@ -0,0 +1,206 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Editor } from '@tiptap/react' +import { entryFixture } from '../test/fixtures' +import { richEditorDocumentFromHtml, richEditorDocumentFromPlainText, richEditorDocumentToHtml, richEditorDocumentToPlainText } from '../editor/editorDocument' +import { registerRichEditor } from '../editor/richEditorRegistry' +import { cleanupControllerTest, deferred, getTauriMock, setupControllerTest, useAppController } from './useAppController.testHarness' + +const tauriMock = getTauriMock() + +describe('useAppController Note autosave writes', () => { + beforeEach(setupControllerTest) + afterEach(cleanupControllerTest) + + it('rejects stale inline-image materialization after the Note edit is discarded', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + const imageImport = deferred<{ id: string; filename: string }>() + tauriMock.importClipboardScreenshot.mockReturnValueOnce(imageImport.promise) + act(() => result.current.setNoteBody(richEditorDocumentFromHtml( + '

Transient image

inline', + ))) + + let savePromise!: Promise + act(() => { savePromise = result.current.saveNoteNow() }) + await waitFor(() => expect(tauriMock.importClipboardScreenshot).toHaveBeenCalledTimes(1)) + expect(result.current.busyAction).toBe('save-body') + expect(result.current.sessionSaveState).toBe('saving') + await act(async () => { await result.current.discardPendingSessionEdits() }) + await act(async () => { + imageImport.resolve({ id: 'attachment-late', filename: 'late.png' }) + await savePromise + }) + + expect(tauriMock.updateEntry).not.toHaveBeenCalled() + expect(tauriMock.deleteAttachment).toHaveBeenCalledWith('attachment-late') + expect(richEditorDocumentToPlainText(result.current.noteBody)).toContain('Checkout fails after payment') + expect(result.current.sessionSaveState).toBe('saved') + expect(result.current.busyAction).toBeNull() + }) + + it('rejects older inline-image materialization after a newer Note save intent', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + const imageImport = deferred<{ id: string; filename: string }>() + tauriMock.importClipboardScreenshot.mockReturnValueOnce(imageImport.promise) + act(() => result.current.setNoteBody(richEditorDocumentFromHtml( + '

Older image intent

', + ))) + let olderSave!: Promise + act(() => { olderSave = result.current.saveNoteNow() }) + await waitFor(() => expect(tauriMock.importClipboardScreenshot).toHaveBeenCalledTimes(1)) + + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Newest authored intent'))) + await act(async () => { await result.current.saveNoteNow() }) + await act(async () => { + imageImport.resolve({ id: 'attachment-stale', filename: 'stale.png' }) + await olderSave + }) + + expect(tauriMock.updateEntry).toHaveBeenCalledTimes(1) + expect(tauriMock.updateEntry).toHaveBeenCalledWith( + 'entry-1', + expect.objectContaining({ body: expect.stringContaining('Newest authored intent') }), + ) + expect(tauriMock.deleteAttachment).toHaveBeenCalledWith('attachment-stale') + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('Newest authored intent') + }) + + it('keeps an imported image retained by a newer Note edit after the older write resolves', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + tauriMock.importClipboardScreenshot.mockResolvedValueOnce({ id: 'attachment-retained', filename: 'retained.png' }) + const olderWrite = deferred>() + tauriMock.updateEntry.mockReturnValueOnce(olderWrite.promise) + act(() => result.current.setNoteBody(richEditorDocumentFromHtml( + '

Older body

retained', + ))) + + let savePromise!: Promise + act(() => { savePromise = result.current.saveNoteNow() }) + await waitFor(() => expect(tauriMock.updateEntry).toHaveBeenCalledTimes(1)) + act(() => result.current.setNoteBody(richEditorDocumentFromHtml( + '

Newer body

retained', + ))) + await act(async () => { + olderWrite.resolve(entryFixture({ + body: '

Older body

retained', + })) + await savePromise + }) + + expect(tauriMock.deleteAttachment).not.toHaveBeenCalledWith('attachment-retained') + expect(richEditorDocumentToHtml(result.current.noteBody)).toContain('attachment-retained') + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('Newer body') + }) + + it('reuses an ordinary Note image after save failure and cleans it on discard', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + tauriMock.importClipboardScreenshot.mockResolvedValue({ id: 'attachment-note-retry', filename: 'retry.png' }) + tauriMock.updateEntry + .mockRejectedValueOnce(new Error('first save offline')) + .mockRejectedValueOnce(new Error('second save offline')) + act(() => result.current.setNoteBody(richEditorDocumentFromHtml( + '

Retry image

Evidence', + ))) + + await act(async () => { expect(await result.current.saveNoteNow()).toBe(false) }) + await act(async () => { expect(await result.current.saveNoteNow()).toBe(false) }) + expect(tauriMock.importClipboardScreenshot).toHaveBeenCalledTimes(1) + expect(tauriMock.updateEntry).toHaveBeenLastCalledWith( + 'entry-1', + expect.objectContaining({ body: expect.stringContaining('attachment-note-retry') }), + ) + + await act(async () => { await result.current.discardPendingSessionEdits() }) + expect(tauriMock.deleteAttachment).toHaveBeenCalledWith('attachment-note-retry') + expect(richEditorDocumentToPlainText(result.current.noteBody)).toContain('Checkout fails after payment') + }) + + it('cleans a direct Note upload when its save fails and the edit is discarded', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + tauriMock.importClipboardScreenshot.mockResolvedValueOnce({ id: 'attachment-direct-discard', filename: 'direct.png' }) + tauriMock.updateEntry.mockRejectedValueOnce(new Error('save offline')) + const editorId = 'direct-discard-editor' + const insertImage = vi.fn((attachmentId: string) => { + result.current.setNoteBody(richEditorDocumentFromHtml( + `

Direct image

Evidence`, + )) + return true + }) + const unregister = registerRichEditor(editorId, { + editor: {} as Editor, + insertImage, + readOnly: false, + }) + + await act(async () => { + await result.current.uploadEditorImage({ + editorId, + file: new File(['image'], 'direct.png', { type: 'image/png' }), + insertImage, + }, { kind: 'note', id: result.current.noteEntry!.id }) + }) + await act(async () => { expect(await result.current.saveNoteNow()).toBe(false) }) + await act(async () => { await result.current.discardPendingSessionEdits() }) + + expect(tauriMock.deleteAttachment).toHaveBeenCalledWith('attachment-direct-discard') + expect(richEditorDocumentToPlainText(result.current.noteBody)).toContain('Checkout fails after payment') + unregister() + }) + + it('does not let a stale Note save response undo a later discard', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + const save = deferred>() + tauriMock.updateEntry.mockReturnValueOnce(save.promise) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Save that will become stale'))) + let savePromise!: Promise + act(() => { savePromise = result.current.saveNoteNow() }) + await waitFor(() => expect(tauriMock.updateEntry).toHaveBeenCalledTimes(1)) + + await act(async () => { await result.current.discardPendingSessionEdits() }) + await act(async () => { + save.resolve(entryFixture({ body: '

Save that will become stale

' })) + await savePromise + }) + + expect(tauriMock.updateEntry).toHaveBeenCalledTimes(2) + expect(tauriMock.updateEntry).toHaveBeenLastCalledWith( + 'entry-1', + expect.objectContaining({ body: expect.stringContaining('Checkout fails after payment') }), + ) + expect(richEditorDocumentToPlainText(result.current.noteBody)).toContain('Checkout fails after payment') + expect(result.current.sessionSaveState).toBe('saved') + expect(result.current.busyAction).toBeNull() + }) + + it('rejects an older Note save response after a newer authored intent', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + const olderSave = deferred>() + tauriMock.updateEntry.mockReturnValueOnce(olderSave.promise) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Older save intent'))) + let olderSavePromise!: Promise + act(() => { olderSavePromise = result.current.saveNoteNow() }) + await waitFor(() => expect(tauriMock.updateEntry).toHaveBeenCalledTimes(1)) + + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Newer authored intent'))) + await act(async () => { + olderSave.resolve(entryFixture({ body: '

Older save intent

' })) + await olderSavePromise + }) + + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('Newer authored intent') + expect(result.current.sessionSaveState).toBe('unsaved') + await act(async () => { await result.current.saveNoteNow() }) + expect(tauriMock.updateEntry).toHaveBeenLastCalledWith( + 'entry-1', + expect.objectContaining({ body: expect.stringContaining('Newer authored intent') }), + ) + expect(result.current.sessionSaveState).toBe('saved') + }) +}) diff --git a/frontend/src/app/useAppController.autosave.records.test.ts b/frontend/src/app/useAppController.autosave.records.test.ts new file mode 100644 index 0000000..cae4784 --- /dev/null +++ b/frontend/src/app/useAppController.autosave.records.test.ts @@ -0,0 +1,179 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { draftFixture, entryFixture, findingFixture, sessionFixture } from '../test/fixtures' +import { cleanupControllerTest, deferred, getTauriMock, sessionNoteStateFixture, setupControllerTest, useAppController } from './useAppController.testHarness' + +const tauriMock = getTauriMock() + +describe('useAppController Draft and Finding autosave', () => { + beforeEach(setupControllerTest) + afterEach(cleanupControllerTest) + + it('flushes dirty Draft and Finding edits before switching to another note', async () => { + tauriMock.openSessionNoteState.mockResolvedValueOnce( + sessionNoteStateFixture({ + testwareDraftCount: 1, + findingCount: 1, + }), + ) + tauriMock.listDrafts.mockResolvedValueOnce([draftFixture({ id: 'draft-dirty', sessionId: 'session-1', body: '

Persisted draft.

' })]) + tauriMock.listFindings.mockResolvedValueOnce([findingFixture({ id: 'finding-dirty', sessionId: 'session-1', body: '

Persisted finding.

' })]) + + const { result } = renderHook(() => useAppController()) + + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => { + result.current.setActiveView('testware') + }) + await waitFor(() => expect(result.current.testwareDrafts.map((draft) => draft.id)).toEqual(['draft-dirty'])) + act(() => { + result.current.setActiveView('findings') + }) + await waitFor(() => expect(result.current.findings.map((finding) => finding.id)).toEqual(['finding-dirty'])) + + act(() => { + result.current.updateLocalDraft('draft-dirty', { body: '

Unsaved draft edit.

' }) + result.current.updateLocalFinding('finding-dirty', { body: '

Unsaved finding edit.

' }) + }) + + const otherSession = sessionFixture({ id: 'session-2', title: 'Other session' }) + tauriMock.openSessionNoteState.mockResolvedValueOnce( + sessionNoteStateFixture({ + session: otherSession, + noteEntry: entryFixture({ id: 'entry-2', sessionId: 'session-2' }), + }), + ) + + await act(async () => { + await result.current.openSession(otherSession) + }) + + expect(tauriMock.updateDraft).toHaveBeenCalledWith('draft-dirty', expect.objectContaining({ body: '

Unsaved draft edit.

' })) + expect(tauriMock.updateFinding).toHaveBeenCalledWith('finding-dirty', expect.objectContaining({ body: '

Unsaved finding edit.

' })) + expect(tauriMock.openSessionNoteState).toHaveBeenCalledWith('session-2') + expect(result.current.activeSession?.id).toBe('session-2') + }) + + it('retries a Draft edit made while a forced record save is in flight and cancels that navigation', async () => { + tauriMock.openSessionNoteState.mockResolvedValueOnce( + sessionNoteStateFixture({ testwareDraftCount: 1 }), + ) + tauriMock.listDrafts.mockResolvedValueOnce([ + draftFixture({ id: 'draft-dirty', sessionId: 'session-1', body: '

Persisted draft.

' }), + ]) + const { result } = renderHook(() => useAppController()) + + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('testware')) + await waitFor(() => expect(result.current.testwareDrafts.map((draft) => draft.id)).toEqual(['draft-dirty'])) + act(() => result.current.updateLocalDraft('draft-dirty', { body: '

First draft edit.

' })) + + const firstSave = deferred>() + tauriMock.updateDraft + .mockReturnValueOnce(firstSave.promise) + .mockResolvedValueOnce(draftFixture({ id: 'draft-dirty', body: '

Latest draft edit.

' })) + const otherSession = sessionFixture({ id: 'session-2', title: 'Other session' }) + tauriMock.openSessionNoteState.mockResolvedValueOnce( + sessionNoteStateFixture({ + session: otherSession, + noteEntry: entryFixture({ id: 'entry-2', sessionId: 'session-2' }), + }), + ) + + let openPromise!: Promise + act(() => { openPromise = result.current.openSession(otherSession) }) + await waitFor(() => expect(tauriMock.updateDraft).toHaveBeenCalledTimes(1)) + act(() => { + result.current.updateLocalDraft('draft-dirty', { body: '

Latest draft edit.

' }) + firstSave.resolve(draftFixture({ id: 'draft-dirty', body: '

First draft edit.

' })) + }) + + await waitFor(() => expect(tauriMock.updateDraft).toHaveBeenCalledTimes(2)) + expect(tauriMock.updateDraft).toHaveBeenLastCalledWith( + 'draft-dirty', + expect.objectContaining({ body: '

Latest draft edit.

' }), + ) + await act(async () => openPromise) + expect(result.current.activeSession?.id).toBe('session-1') + + await act(async () => { await result.current.openSession(otherSession) }) + expect(result.current.activeSession?.id).toBe('session-2') + }) + + it('retries a Finding edit made while a forced record save is in flight and cancels that navigation', async () => { + tauriMock.openSessionNoteState.mockResolvedValueOnce( + sessionNoteStateFixture({ findingCount: 1 }), + ) + tauriMock.listFindings.mockResolvedValueOnce([ + findingFixture({ id: 'finding-dirty', sessionId: 'session-1', body: '

Persisted finding.

' }), + ]) + const { result } = renderHook(() => useAppController()) + + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('findings')) + await waitFor(() => expect(result.current.findings.map((finding) => finding.id)).toEqual(['finding-dirty'])) + act(() => result.current.updateLocalFinding('finding-dirty', { body: '

First finding edit.

' })) + + const firstSave = deferred>() + tauriMock.updateFinding + .mockReturnValueOnce(firstSave.promise) + .mockResolvedValueOnce(findingFixture({ id: 'finding-dirty', body: '

Latest finding edit.

' })) + const otherSession = sessionFixture({ id: 'session-2', title: 'Other session' }) + tauriMock.openSessionNoteState.mockResolvedValueOnce( + sessionNoteStateFixture({ + session: otherSession, + noteEntry: entryFixture({ id: 'entry-2', sessionId: 'session-2' }), + }), + ) + + let openPromise!: Promise + act(() => { openPromise = result.current.openSession(otherSession) }) + await waitFor(() => expect(tauriMock.updateFinding).toHaveBeenCalledTimes(1)) + act(() => { + result.current.updateLocalFinding('finding-dirty', { body: '

Latest finding edit.

' }) + firstSave.resolve(findingFixture({ id: 'finding-dirty', body: '

First finding edit.

' })) + }) + + await waitFor(() => expect(tauriMock.updateFinding).toHaveBeenCalledTimes(2)) + expect(tauriMock.updateFinding).toHaveBeenLastCalledWith( + 'finding-dirty', + expect.objectContaining({ body: '

Latest finding edit.

' }), + ) + await act(async () => openPromise) + expect(result.current.activeSession?.id).toBe('session-1') + + await act(async () => { await result.current.openSession(otherSession) }) + expect(result.current.activeSession?.id).toBe('session-2') + }) + + it('does not switch sessions when a dirty record flush fails', async () => { + tauriMock.openSessionNoteState.mockResolvedValueOnce( + sessionNoteStateFixture({ + testwareDraftCount: 1, + }), + ) + tauriMock.listDrafts.mockResolvedValueOnce([draftFixture({ id: 'draft-dirty', sessionId: 'session-1' })]) + + const { result } = renderHook(() => useAppController()) + + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => { + result.current.setActiveView('testware') + }) + await waitFor(() => expect(result.current.testwareDrafts.map((draft) => draft.id)).toEqual(['draft-dirty'])) + + act(() => { + result.current.updateLocalDraft('draft-dirty', { body: '

Flush will fail.

' }) + }) + tauriMock.updateDraft.mockRejectedValueOnce(new Error('offline')) + + const otherSession = sessionFixture({ id: 'session-2', title: 'Other session' }) + await act(async () => { + await result.current.openSession(otherSession) + }) + + expect(tauriMock.openSessionNoteState).not.toHaveBeenCalledWith('session-2') + expect(result.current.activeSession?.id).toBe('session-1') + expect(result.current.error).toBeTruthy() + }) +}) diff --git a/frontend/src/app/useAppController.autosave.recovered-summary.test.ts b/frontend/src/app/useAppController.autosave.recovered-summary.test.ts new file mode 100644 index 0000000..68f1eaa --- /dev/null +++ b/frontend/src/app/useAppController.autosave.recovered-summary.test.ts @@ -0,0 +1,101 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { entryFixture, generationStatusFixture, sessionFixture } from '../test/fixtures' +import { richEditorDocumentFromPlainText } from '../editor/editorDocument' +import { cleanupControllerTest, getTauriMock, sessionNoteStateFixture, setupControllerTest, useAppController } from './useAppController.testHarness' + +const tauriMock = getTauriMock() + +describe('useAppController autosave with recovered Summary jobs', () => { + beforeEach(setupControllerTest) + afterEach(cleanupControllerTest) + + it('blocks ambient and forced stale Note saves while a recovered Summary is unresolved', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'running' }), + ]) + tauriMock.getAiActionJobStatus.mockResolvedValue( + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'running' }), + ) + tauriMock.openSessionNoteState.mockResolvedValueOnce( + sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Stale Note.

' }) }), + ) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Dirty stale edit'))) + + await act(async () => { await vi.advanceTimersByTimeAsync(900) }) + expect(tauriMock.updateEntry).not.toHaveBeenCalled() + + let forcedSave = true + await act(async () => { forcedSave = await result.current.saveNoteNow() }) + expect(forcedSave).toBe(false) + + const otherSession = sessionFixture({ id: 'session-2', title: 'Other session' }) + await act(async () => { await result.current.openSession(otherSession) }) + expect(tauriMock.openSessionNoteState).not.toHaveBeenCalledWith('session-2') + expect(result.current.activeSession?.id).toBe('session-1') + expect(tauriMock.updateEntry).not.toHaveBeenCalled() + }) + + it('flushes a blocked ambient save after all recovered Summary jobs fail or cancel', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-failed', action: 'summary', state: 'running' }), + generationStatusFixture({ jobId: 'job-cancelled', action: 'summary', state: 'running' }), + ]) + let cancelledJobPolls = 0 + tauriMock.getAiActionJobStatus.mockImplementation(async (jobId: string) => { + if (jobId === 'job-failed') { + return generationStatusFixture({ jobId, action: 'summary', state: 'failed', errorMessage: 'Generation failed' }) + } + cancelledJobPolls += 1 + return generationStatusFixture({ + jobId, + action: 'summary', + state: cancelledJobPolls === 1 ? 'running' : 'cancelled', + }) + }) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Edit waiting for both jobs'))) + + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + let saved = true + await act(async () => { saved = await result.current.saveNoteNow() }) + expect(saved).toBe(false) + expect(tauriMock.updateEntry).not.toHaveBeenCalled() + + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + expect(tauriMock.updateEntry).toHaveBeenCalledWith( + 'entry-1', + expect.objectContaining({ body: expect.stringContaining('Edit waiting for both jobs') }), + ) + }) + + it('does not block Note saves for a different Session or a recovered non-Summary job', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-other-summary', sessionId: 'session-2', action: 'summary', state: 'running' }), + generationStatusFixture({ jobId: 'job-testware', sessionId: 'session-1', action: 'testware', state: 'running' }), + ]) + tauriMock.getAiActionJobStatus.mockResolvedValue( + generationStatusFixture({ jobId: 'job-testware', action: 'testware', state: 'running' }), + ) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Session one remains saveable'))) + + let saved = false + await act(async () => { saved = await result.current.saveNoteNow() }) + expect(saved).toBe(true) + expect(tauriMock.updateEntry).toHaveBeenCalledWith( + 'entry-1', + expect.objectContaining({ body: expect.stringContaining('Session one remains saveable') }), + ) + }) +}) diff --git a/frontend/src/app/useAppController.autosave.test.ts b/frontend/src/app/useAppController.autosave.test.ts index 7be5dcb..2999253 100644 --- a/frontend/src/app/useAppController.autosave.test.ts +++ b/frontend/src/app/useAppController.autosave.test.ts @@ -1,7 +1,7 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { draftFixture, entryFixture, findingFixture, sessionFixture } from '../test/fixtures' -import { richEditorDocumentFromPlainText } from '../editor/editorDocument' +import { entryFixture, sessionFixture } from '../test/fixtures' +import { richEditorDocumentFromPlainText, richEditorDocumentToPlainText } from '../editor/editorDocument' import { cleanupControllerTest, deferred, getTauriMock, sessionNoteStateFixture, setupControllerTest, useAppController } from './useAppController.testHarness' const tauriMock = getTauriMock() @@ -120,13 +120,6 @@ describe('useAppController autosave and close protection', () => { const firstSave = deferred>() tauriMock.updateEntry.mockReturnValueOnce(firstSave.promise) const otherSession = sessionFixture({ id: 'session-2', title: 'Other session' }) - tauriMock.openSessionNoteState.mockResolvedValueOnce( - sessionNoteStateFixture({ - session: otherSession, - noteEntry: entryFixture({ id: 'entry-2', sessionId: 'session-2' }), - }), - ) - let openPromise!: Promise act(() => { openPromise = result.current.openSession(otherSession) @@ -138,13 +131,20 @@ describe('useAppController autosave and close protection', () => { firstSave.resolve(entryFixture({ body: '

first edit

' })) }) - await waitFor(() => expect(tauriMock.updateEntry).toHaveBeenCalledTimes(2)) + await act(async () => openPromise) + expect(result.current.activeSession?.id).toBe('session-1') + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('edit typed while saving') + tauriMock.openSessionNoteState.mockResolvedValueOnce( + sessionNoteStateFixture({ + session: otherSession, + noteEntry: entryFixture({ id: 'entry-2', sessionId: 'session-2' }), + }), + ) + await act(async () => { await result.current.openSession(otherSession) }) expect(tauriMock.updateEntry).toHaveBeenLastCalledWith( 'entry-1', expect.objectContaining({ body: expect.stringContaining('edit typed while saving') }), ) - - await act(async () => openPromise) expect(result.current.activeSession?.id).toBe('session-2') }) @@ -167,168 +167,6 @@ describe('useAppController autosave and close protection', () => { ) }) - it('flushes dirty Draft and Finding edits before switching to another note', async () => { - tauriMock.openSessionNoteState.mockResolvedValueOnce( - sessionNoteStateFixture({ - testwareDraftCount: 1, - findingCount: 1, - }), - ) - tauriMock.listDrafts.mockResolvedValueOnce([draftFixture({ id: 'draft-dirty', sessionId: 'session-1', body: '

Persisted draft.

' })]) - tauriMock.listFindings.mockResolvedValueOnce([findingFixture({ id: 'finding-dirty', sessionId: 'session-1', body: '

Persisted finding.

' })]) - - const { result } = renderHook(() => useAppController()) - - await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) - act(() => { - result.current.setActiveView('testware') - }) - await waitFor(() => expect(result.current.testwareDrafts.map((draft) => draft.id)).toEqual(['draft-dirty'])) - act(() => { - result.current.setActiveView('findings') - }) - await waitFor(() => expect(result.current.findings.map((finding) => finding.id)).toEqual(['finding-dirty'])) - - act(() => { - result.current.updateLocalDraft('draft-dirty', { body: '

Unsaved draft edit.

' }) - result.current.updateLocalFinding('finding-dirty', { body: '

Unsaved finding edit.

' }) - }) - - const otherSession = sessionFixture({ id: 'session-2', title: 'Other session' }) - tauriMock.openSessionNoteState.mockResolvedValueOnce( - sessionNoteStateFixture({ - session: otherSession, - noteEntry: entryFixture({ id: 'entry-2', sessionId: 'session-2' }), - }), - ) - - await act(async () => { - await result.current.openSession(otherSession) - }) - - expect(tauriMock.updateDraft).toHaveBeenCalledWith('draft-dirty', expect.objectContaining({ body: '

Unsaved draft edit.

' })) - expect(tauriMock.updateFinding).toHaveBeenCalledWith('finding-dirty', expect.objectContaining({ body: '

Unsaved finding edit.

' })) - expect(tauriMock.openSessionNoteState).toHaveBeenCalledWith('session-2') - expect(result.current.activeSession?.id).toBe('session-2') - }) - - it('retries a Draft edit made while a forced record save is in flight', async () => { - tauriMock.openSessionNoteState.mockResolvedValueOnce( - sessionNoteStateFixture({ testwareDraftCount: 1 }), - ) - tauriMock.listDrafts.mockResolvedValueOnce([ - draftFixture({ id: 'draft-dirty', sessionId: 'session-1', body: '

Persisted draft.

' }), - ]) - const { result } = renderHook(() => useAppController()) - - await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) - act(() => result.current.setActiveView('testware')) - await waitFor(() => expect(result.current.testwareDrafts.map((draft) => draft.id)).toEqual(['draft-dirty'])) - act(() => result.current.updateLocalDraft('draft-dirty', { body: '

First draft edit.

' })) - - const firstSave = deferred>() - tauriMock.updateDraft - .mockReturnValueOnce(firstSave.promise) - .mockResolvedValueOnce(draftFixture({ id: 'draft-dirty', body: '

Latest draft edit.

' })) - const otherSession = sessionFixture({ id: 'session-2', title: 'Other session' }) - tauriMock.openSessionNoteState.mockResolvedValueOnce( - sessionNoteStateFixture({ - session: otherSession, - noteEntry: entryFixture({ id: 'entry-2', sessionId: 'session-2' }), - }), - ) - - let openPromise!: Promise - act(() => { openPromise = result.current.openSession(otherSession) }) - await waitFor(() => expect(tauriMock.updateDraft).toHaveBeenCalledTimes(1)) - act(() => { - result.current.updateLocalDraft('draft-dirty', { body: '

Latest draft edit.

' }) - firstSave.resolve(draftFixture({ id: 'draft-dirty', body: '

First draft edit.

' })) - }) - - await waitFor(() => expect(tauriMock.updateDraft).toHaveBeenCalledTimes(2)) - expect(tauriMock.updateDraft).toHaveBeenLastCalledWith( - 'draft-dirty', - expect.objectContaining({ body: '

Latest draft edit.

' }), - ) - await act(async () => openPromise) - expect(result.current.activeSession?.id).toBe('session-2') - }) - - it('retries a Finding edit made while a forced record save is in flight', async () => { - tauriMock.openSessionNoteState.mockResolvedValueOnce( - sessionNoteStateFixture({ findingCount: 1 }), - ) - tauriMock.listFindings.mockResolvedValueOnce([ - findingFixture({ id: 'finding-dirty', sessionId: 'session-1', body: '

Persisted finding.

' }), - ]) - const { result } = renderHook(() => useAppController()) - - await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) - act(() => result.current.setActiveView('findings')) - await waitFor(() => expect(result.current.findings.map((finding) => finding.id)).toEqual(['finding-dirty'])) - act(() => result.current.updateLocalFinding('finding-dirty', { body: '

First finding edit.

' })) - - const firstSave = deferred>() - tauriMock.updateFinding - .mockReturnValueOnce(firstSave.promise) - .mockResolvedValueOnce(findingFixture({ id: 'finding-dirty', body: '

Latest finding edit.

' })) - const otherSession = sessionFixture({ id: 'session-2', title: 'Other session' }) - tauriMock.openSessionNoteState.mockResolvedValueOnce( - sessionNoteStateFixture({ - session: otherSession, - noteEntry: entryFixture({ id: 'entry-2', sessionId: 'session-2' }), - }), - ) - - let openPromise!: Promise - act(() => { openPromise = result.current.openSession(otherSession) }) - await waitFor(() => expect(tauriMock.updateFinding).toHaveBeenCalledTimes(1)) - act(() => { - result.current.updateLocalFinding('finding-dirty', { body: '

Latest finding edit.

' }) - firstSave.resolve(findingFixture({ id: 'finding-dirty', body: '

First finding edit.

' })) - }) - - await waitFor(() => expect(tauriMock.updateFinding).toHaveBeenCalledTimes(2)) - expect(tauriMock.updateFinding).toHaveBeenLastCalledWith( - 'finding-dirty', - expect.objectContaining({ body: '

Latest finding edit.

' }), - ) - await act(async () => openPromise) - expect(result.current.activeSession?.id).toBe('session-2') - }) - - it('does not switch sessions when a dirty record flush fails', async () => { - tauriMock.openSessionNoteState.mockResolvedValueOnce( - sessionNoteStateFixture({ - testwareDraftCount: 1, - }), - ) - tauriMock.listDrafts.mockResolvedValueOnce([draftFixture({ id: 'draft-dirty', sessionId: 'session-1' })]) - - const { result } = renderHook(() => useAppController()) - - await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) - act(() => { - result.current.setActiveView('testware') - }) - await waitFor(() => expect(result.current.testwareDrafts.map((draft) => draft.id)).toEqual(['draft-dirty'])) - - act(() => { - result.current.updateLocalDraft('draft-dirty', { body: '

Flush will fail.

' }) - }) - tauriMock.updateDraft.mockRejectedValueOnce(new Error('offline')) - - const otherSession = sessionFixture({ id: 'session-2', title: 'Other session' }) - await act(async () => { - await result.current.openSession(otherSession) - }) - - expect(tauriMock.openSessionNoteState).not.toHaveBeenCalledWith('session-2') - expect(result.current.activeSession?.id).toBe('session-1') - expect(result.current.error).toBeTruthy() - }) - it('flushes a pending title edit before switching to another note', async () => { const { result } = renderHook(() => useAppController()) @@ -372,6 +210,7 @@ describe('useAppController autosave and close protection', () => { }) expect(tauriMock.deleteSession).toHaveBeenCalledWith('session-1') + expect(result.current.sessions.map((session) => session.id)).not.toContain('session-1') // If active-Session state (or the guard) were cleared incorrectly, a leftover // title/body debounce could still fire an autosave against the deleted session. @@ -390,4 +229,5 @@ describe('useAppController autosave and close protection', () => { vi.useRealTimers() } }) + }) diff --git a/frontend/src/app/useAppController.closeProtection.summaryRecovery.test.ts b/frontend/src/app/useAppController.closeProtection.summaryRecovery.test.ts new file mode 100644 index 0000000..eb1575e --- /dev/null +++ b/frontend/src/app/useAppController.closeProtection.summaryRecovery.test.ts @@ -0,0 +1,316 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { entryFixture, generationStatusFixture, sessionFixture } from '../test/fixtures' +import { richEditorDocumentFromPlainText } from '../editor/editorDocument' +import { + cleanupControllerTest, + deferred, + getTauriMock, + getTauriWindowMock, + setupControllerTest, + useAppController, +} from './useAppController.testHarness' + +const tauriMock = getTauriMock() +const tauriWindowMock = getTauriWindowMock() + +describe('useAppController close protection: Summary recovery', () => { + beforeEach(setupControllerTest) + afterEach(cleanupControllerTest) + + it('keeps failed generation undo dirty so beforeunload can retry the save', async () => { + const { result } = renderHook(() => useAppController()) + + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + tauriMock.startAiActionJob.mockImplementationOnce(async (_request: unknown, onEvent: (event: unknown) => void) => { + onEvent({ + type: 'completed', + job_id: 'job-summary', + status: generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'completed' }), + result: { + generationContext: { id: 'context-1', sessionId: 'session-1', createdAt: '2026-06-24T10:00:00.000Z' }, + aiRun: { + id: 'run-1', + sessionId: 'session-1', + generationContextId: 'context-1', + provider: 'codex_cli', + model: 'default', + reasoningEffort: null, + promptVersion: 'summary-v1', + status: 'completed', + errorMessage: null, + createdAt: '2026-06-24T10:00:00.000Z', + completedAt: '2026-06-24T10:00:00.000Z', + }, + draft: null, + finding: null, + noteEntry: entryFixture({ body: '

Generated summary.

' }), + }, + }) + return { jobId: 'job-summary', status: generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'completed' }) } + }) + + await act(async () => { + await result.current.handleAiAction('summary') + }) + await waitFor(() => expect(result.current.notice).toBe('Summarizing note')) + expect(result.current.pendingRecoveredSummaryDecision).toBe(false) + expect(result.current.sessionSaveState).toBe('saved') + // The backend already persisted the generated Summary. With no images to + // preserve, the frontend does not issue a redundant compensating write. + expect(tauriMock.updateEntry).not.toHaveBeenCalled() + + tauriMock.updateEntry.mockRejectedValueOnce(new Error('offline')) + await act(async () => { + await result.current.handleUndoLatestNoteGeneration() + }) + await waitFor(() => expect(result.current.error).toBeTruthy()) + + const event = new Event('beforeunload', { cancelable: true }) + await act(async () => { + window.dispatchEvent(event) + await Promise.resolve() + await Promise.resolve() + }) + + expect(event.defaultPrevented).toBe(true) + expect(tauriMock.updateEntry).toHaveBeenLastCalledWith( + 'entry-1', + expect.objectContaining({ body: expect.stringContaining('Checkout fails after payment') }), + ) + }) + + it('persists authored pre-recovery text before allowing native close', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'running' }), + ]) + tauriMock.getAiActionJobStatus.mockResolvedValue( + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'completed' }), + ) + tauriMock.openSessionNoteState + .mockResolvedValueOnce({ + session: sessionFixture(), + noteEntry: entryFixture({ body: '

Startup Note.

' }), + testwareDraftCount: 0, + findingCount: 0, + }) + .mockResolvedValueOnce({ + session: sessionFixture(), + noteEntry: entryFixture({ body: '

Generated Summary.

' }), + testwareDraftCount: 0, + findingCount: 0, + }) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Authored before recovered close'))) + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + expect(result.current.pendingRecoveredSummaryDecision).toBe(true) + + const closeEvent = { preventDefault: vi.fn() } + await act(async () => { await tauriWindowMock.closeRequestedHandler()?.(closeEvent) }) + + expect(closeEvent.preventDefault).toHaveBeenCalled() + expect(tauriMock.updateEntry).toHaveBeenCalledWith( + 'entry-1', + expect.objectContaining({ body: expect.stringContaining('Authored before recovered close') }), + ) + expect(tauriWindowMock.currentWindow.destroy).toHaveBeenCalledTimes(1) + }) + + it('persists an edited recovered Summary before allowing native close', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'running' }), + ]) + tauriMock.getAiActionJobStatus.mockResolvedValue( + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'completed' }), + ) + tauriMock.openSessionNoteState + .mockResolvedValueOnce({ + session: sessionFixture(), + noteEntry: entryFixture({ body: '

Startup Note.

' }), + testwareDraftCount: 0, + findingCount: 0, + }) + .mockResolvedValueOnce({ + session: sessionFixture(), + noteEntry: entryFixture({ body: '

Generated Summary.

' }), + testwareDraftCount: 0, + findingCount: 0, + }) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Authored before recovery'))) + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Edited recovered Summary'))) + + const closeEvent = { preventDefault: vi.fn() } + await act(async () => { await tauriWindowMock.closeRequestedHandler()?.(closeEvent) }) + + expect(tauriMock.updateEntry).toHaveBeenLastCalledWith( + 'entry-1', + expect.objectContaining({ body: '

Edited recovered Summary

' }), + ) + expect(tauriWindowMock.currentWindow.destroy).toHaveBeenCalledTimes(1) + }) + + it('retries a failed Keep generated Summary choice on native close', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'running' }), + ]) + tauriMock.getAiActionJobStatus.mockResolvedValue( + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'completed' }), + ) + tauriMock.openSessionNoteState + .mockResolvedValueOnce({ + session: sessionFixture(), + noteEntry: entryFixture({ body: '

Startup Note.

' }), + testwareDraftCount: 0, + findingCount: 0, + }) + .mockResolvedValueOnce({ + session: sessionFixture(), + noteEntry: entryFixture({ body: '

Generated Summary.

' }), + testwareDraftCount: 0, + findingCount: 0, + }) + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Authored before recovery'))) + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + expect(result.current.pendingRecoveredSummaryDecision).toBe(true) + act(() => { + result.current.setNoteBody(richEditorDocumentFromPlainText('Edited generated Summary')) + result.current.setActiveView('settings') + }) + tauriMock.updateEntry + .mockRejectedValueOnce(new Error('generated choice offline')) + .mockResolvedValueOnce(entryFixture({ body: '

Edited generated Summary

' })) + + await act(async () => { await result.current.discardPendingNavigationChanges() }) + expect(result.current.pendingRecoveredSummaryDecision).toBe(true) + expect(result.current.error).toContain('generated choice offline') + + const closeEvent = { preventDefault: vi.fn() } + await act(async () => { await tauriWindowMock.closeRequestedHandler()?.(closeEvent) }) + + expect(closeEvent.preventDefault).toHaveBeenCalled() + expect(tauriMock.updateEntry).toHaveBeenLastCalledWith( + 'entry-1', + expect.objectContaining({ body: '

Edited generated Summary

' }), + ) + expect(tauriWindowMock.currentWindow.destroy).toHaveBeenCalledTimes(1) + }) + + it('allows Restore authored text to supersede a failed Keep generated Summary choice', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'running' }), + ]) + tauriMock.getAiActionJobStatus.mockResolvedValue( + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'completed' }), + ) + tauriMock.openSessionNoteState + .mockResolvedValueOnce({ + session: sessionFixture(), + noteEntry: entryFixture({ body: '

Startup Note.

' }), + testwareDraftCount: 0, + findingCount: 0, + }) + .mockResolvedValueOnce({ + session: sessionFixture(), + noteEntry: entryFixture({ body: '

Generated Summary.

' }), + testwareDraftCount: 0, + findingCount: 0, + }) + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Authored before recovery'))) + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + expect(result.current.pendingRecoveredSummaryDecision).toBe(true) + act(() => { + result.current.setNoteBody(richEditorDocumentFromPlainText('Edited generated Summary')) + result.current.setActiveView('settings') + }) + tauriMock.updateEntry + .mockRejectedValueOnce(new Error('generated choice offline')) + .mockRejectedValueOnce(new Error('authored choice offline')) + .mockResolvedValueOnce(entryFixture({ body: '

Authored after failed restore

' })) + + await act(async () => { await result.current.discardPendingNavigationChanges() }) + expect(result.current.pendingRecoveredSummaryDecision).toBe(true) + await act(async () => { await result.current.savePendingNavigationChanges() }) + expect(result.current.pendingRecoveredSummaryDecision).toBe(true) + expect(result.current.error).toContain('authored choice offline') + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Authored after failed restore'))) + await act(async () => { await result.current.savePendingNavigationChanges() }) + + expect(tauriMock.updateEntry).toHaveBeenLastCalledWith( + 'entry-1', + expect.objectContaining({ body: '

Authored after failed restore

' }), + ) + expect(result.current.pendingRecoveredSummaryDecision).toBe(false) + expect(result.current.activeView).toBe('settings') + }) + + it('blocks beforeunload and native close while an equal-body recovered Summary decision is pending', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'running' }), + ]) + tauriMock.getAiActionJobStatus.mockResolvedValue( + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'completed' }), + ) + tauriMock.openSessionNoteState + .mockResolvedValueOnce({ + session: sessionFixture(), + noteEntry: entryFixture({ body: '

Startup Note.

' }), + testwareDraftCount: 0, + findingCount: 0, + }) + .mockResolvedValueOnce({ + session: sessionFixture(), + noteEntry: entryFixture({ body: '

Generated Summary.

' }), + testwareDraftCount: 0, + findingCount: 0, + }) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Authored baseline.'))) + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + expect(result.current.pendingRecoveredSummaryDecision).toBe(true) + expect(result.current.sessionSaveState).toBe('unsaved') + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Authored baseline.'))) + + const save = deferred>() + tauriMock.updateEntry.mockReturnValueOnce(save.promise) + const unload = new Event('beforeunload', { cancelable: true }) + await act(async () => { + window.dispatchEvent(unload) + await Promise.resolve() + await Promise.resolve() + }) + expect(unload.defaultPrevented).toBe(true) + + const closeEvent = { preventDefault: vi.fn() } + let closePromise!: Promise + act(() => { closePromise = Promise.resolve(tauriWindowMock.closeRequestedHandler()?.(closeEvent)) }) + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + expect(closeEvent.preventDefault).toHaveBeenCalled() + expect(tauriWindowMock.currentWindow.destroy).not.toHaveBeenCalled() + + await act(async () => { + save.resolve(entryFixture({ body: '

Authored baseline.

' })) + await closePromise + }) + expect(tauriWindowMock.currentWindow.destroy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/app/useAppController.closeProtection.test.ts b/frontend/src/app/useAppController.closeProtection.test.ts index ae3a3f5..a2f9bdf 100644 --- a/frontend/src/app/useAppController.closeProtection.test.ts +++ b/frontend/src/app/useAppController.closeProtection.test.ts @@ -1,7 +1,9 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { entryFixture, generationStatusFixture, sessionFixture } from '../test/fixtures' -import { richEditorDocumentFromPlainText } from '../editor/editorDocument' +import type { Editor } from '@tiptap/react' +import { draftFixture, entryFixture, sessionFixture } from '../test/fixtures' +import { richEditorDocumentFromHtml, richEditorDocumentFromPlainText, richEditorDocumentToPlainText } from '../editor/editorDocument' +import { registerRichEditor } from '../editor/richEditorRegistry' import { cleanupControllerTest, deferred, @@ -156,6 +158,249 @@ describe('useAppController close protection', () => { expect(tauriWindowMock.currentWindow.destroy).toHaveBeenCalledTimes(1) }) + it('waits for a discarded in-flight Note write and its restoration before closing', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + await waitFor(() => expect(tauriWindowMock.closeRequestedHandler()).toBeTruthy()) + const staleWrite = deferred>() + tauriMock.updateEntry + .mockReturnValueOnce(staleWrite.promise) + .mockResolvedValueOnce(entryFixture()) + + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Discarded Note edit'))) + let savePromise!: Promise + act(() => { savePromise = result.current.saveNoteNow() }) + await waitFor(() => expect(tauriMock.updateEntry).toHaveBeenCalledTimes(1)) + await act(async () => { await result.current.discardPendingSessionEdits() }) + + const closeEvent = { preventDefault: vi.fn() } + let closePromise!: Promise + act(() => { closePromise = Promise.resolve(tauriWindowMock.closeRequestedHandler()?.(closeEvent)) }) + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + expect(closeEvent.preventDefault).toHaveBeenCalled() + expect(tauriWindowMock.currentWindow.destroy).not.toHaveBeenCalled() + expect(tauriMock.updateEntry).toHaveBeenCalledTimes(1) + + await act(async () => { + staleWrite.resolve(entryFixture({ body: '

Discarded Note edit

' })) + await Promise.all([savePromise, closePromise]) + }) + + expect(tauriMock.updateEntry.mock.calls.length).toBeGreaterThanOrEqual(2) + for (const [entryId, patch] of tauriMock.updateEntry.mock.calls.slice(1)) { + expect(entryId).toBe('entry-1') + expect(patch).toEqual(expect.objectContaining({ body: '

Checkout fails after payment.

' })) + } + expect(tauriWindowMock.currentWindow.destroy).toHaveBeenCalledTimes(1) + }) + + it('waits for a discarded in-flight Draft write and its restoration before closing', async () => { + const draft = draftFixture({ id: 'draft-1', body: '

Saved Draft

' }) + tauriMock.listDrafts.mockResolvedValueOnce([draft]) + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + await waitFor(() => expect(tauriWindowMock.closeRequestedHandler()).toBeTruthy()) + act(() => result.current.setActiveView('testware')) + await waitFor(() => expect(result.current.testwareDrafts).toEqual([draft])) + const staleWrite = deferred>() + tauriMock.updateDraft + .mockReturnValueOnce(staleWrite.promise) + .mockResolvedValueOnce(draft) + + act(() => result.current.updateLocalDraft(draft.id, { body: '

Discarded Draft edit

' })) + let savePromise!: Promise + act(() => { savePromise = result.current.handleSaveDraft(result.current.testwareDrafts[0]) }) + await waitFor(() => expect(tauriMock.updateDraft).toHaveBeenCalledTimes(1)) + act(() => result.current.discardLocalDraft(draft)) + + const closeEvent = { preventDefault: vi.fn() } + let closePromise!: Promise + act(() => { closePromise = Promise.resolve(tauriWindowMock.closeRequestedHandler()?.(closeEvent)) }) + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + expect(closeEvent.preventDefault).toHaveBeenCalled() + expect(tauriWindowMock.currentWindow.destroy).not.toHaveBeenCalled() + expect(tauriMock.updateDraft).toHaveBeenCalledTimes(1) + + await act(async () => { + staleWrite.resolve(draftFixture({ id: draft.id, body: '

Discarded Draft edit

' })) + await Promise.all([savePromise, closePromise]) + }) + + expect(tauriMock.updateDraft).toHaveBeenCalledTimes(2) + expect(tauriMock.updateDraft).toHaveBeenLastCalledWith( + draft.id, + expect.objectContaining({ body: '

Saved Draft

' }), + ) + expect(tauriWindowMock.currentWindow.destroy).toHaveBeenCalledTimes(1) + }) + + it('waits for and removes an inline-image import superseded by Note discard before closing', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + await waitFor(() => expect(tauriWindowMock.closeRequestedHandler()).toBeTruthy()) + const pendingImport = deferred<{ id: string; filename: string }>() + tauriMock.importClipboardScreenshot.mockReturnValueOnce(pendingImport.promise) + + act(() => result.current.setNoteBody(richEditorDocumentFromHtml( + '

Evidence

', + ))) + let savePromise!: Promise + act(() => { savePromise = result.current.saveNoteNow() }) + await waitFor(() => expect(tauriMock.importClipboardScreenshot).toHaveBeenCalledTimes(1)) + await act(async () => { await result.current.discardPendingSessionEdits() }) + + const closeEvent = { preventDefault: vi.fn() } + let closePromise!: Promise + act(() => { closePromise = Promise.resolve(tauriWindowMock.closeRequestedHandler()?.(closeEvent)) }) + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + expect(closeEvent.preventDefault).toHaveBeenCalled() + expect(tauriWindowMock.currentWindow.destroy).not.toHaveBeenCalled() + expect(tauriMock.deleteAttachment).not.toHaveBeenCalled() + + await act(async () => { + pendingImport.resolve({ id: 'attachment-stale-note', filename: 'Evidence.png' }) + await Promise.all([savePromise, closePromise]) + }) + + expect(tauriMock.deleteAttachment).toHaveBeenCalledWith('attachment-stale-note') + expect(tauriMock.updateEntry).not.toHaveBeenCalled() + expect(tauriWindowMock.currentWindow.destroy).toHaveBeenCalledTimes(1) + }) + + it('waits for a direct upload insertion and saves the resulting Note before closing', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + await waitFor(() => expect(tauriWindowMock.closeRequestedHandler()).toBeTruthy()) + const pendingImport = deferred<{ id: string; filename: string }>() + tauriMock.importClipboardScreenshot.mockReturnValueOnce(pendingImport.promise) + const editorId = 'close-upload-editor' + const insertImage = vi.fn(() => { + result.current.setNoteBody(richEditorDocumentFromHtml( + '

Uploaded before close

close.png', + )) + return true + }) + const unregister = registerRichEditor(editorId, { + editor: {} as Editor, + insertImage, + readOnly: false, + }) + const uploadPromise = result.current.uploadEditorImage({ + editorId, + file: new File(['image'], 'close.png', { type: 'image/png' }), + insertImage, + }, { kind: 'note', id: result.current.noteEntry!.id }) + await waitFor(() => expect(tauriMock.importClipboardScreenshot).toHaveBeenCalledTimes(1)) + + const closeEvent = { preventDefault: vi.fn() } + let closePromise!: Promise + act(() => { closePromise = Promise.resolve(tauriWindowMock.closeRequestedHandler()?.(closeEvent)) }) + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + expect(closeEvent.preventDefault).toHaveBeenCalled() + expect(tauriWindowMock.currentWindow.destroy).not.toHaveBeenCalled() + + await act(async () => { + pendingImport.resolve({ id: 'attachment-close-upload', filename: 'close.png' }) + await Promise.all([uploadPromise, closePromise]) + }) + + expect(insertImage).toHaveBeenCalledTimes(1) + expect(tauriMock.updateEntry).toHaveBeenCalledWith( + 'entry-1', + expect.objectContaining({ body: expect.stringContaining('attachment-close-upload') }), + ) + expect(tauriWindowMock.currentWindow.destroy).toHaveBeenCalledTimes(1) + unregister() + }) + + it('returns from close without destroying when stale attachment cleanup remains blocked', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + await waitFor(() => expect(tauriWindowMock.closeRequestedHandler()).toBeTruthy()) + tauriMock.deleteAttachment.mockResolvedValue(false) + const editorId = 'declined-close-upload-editor' + const insertImage = vi.fn(() => false) + const unregister = registerRichEditor(editorId, { + editor: {} as Editor, + insertImage, + readOnly: false, + }) + await act(async () => { + await result.current.uploadEditorImage({ + editorId, + file: new File(['image'], 'declined.png', { type: 'image/png' }), + insertImage, + }, { kind: 'note', id: result.current.noteEntry!.id }) + }) + + const closeEvent = { preventDefault: vi.fn() } + await act(async () => { await tauriWindowMock.closeRequestedHandler()?.(closeEvent) }) + + expect(closeEvent.preventDefault).toHaveBeenCalled() + expect(tauriMock.deleteAttachment.mock.calls.length).toBeGreaterThanOrEqual(2) + expect(tauriWindowMock.currentWindow.destroy).not.toHaveBeenCalled() + unregister() + }) + + it('rechecks edits made while final attachment cleanup is in flight before closing', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + await waitFor(() => expect(tauriWindowMock.closeRequestedHandler()).toBeTruthy()) + const cleanup = deferred() + const imported = deferred<{ id: string; filename: string }>() + tauriMock.importClipboardScreenshot.mockReturnValueOnce(imported.promise) + const editorId = 'cleanup-close-editor' + const insertImage = vi.fn(() => true) + const unregister = registerRichEditor(editorId, { + editor: {} as Editor, + insertImage, + readOnly: false, + }) + let uploadPromise!: Promise + act(() => { + uploadPromise = result.current.uploadEditorImage({ + editorId, + file: new File(['image'], 'cleanup.png', { type: 'image/png' }), + insertImage, + }, { kind: 'note', id: result.current.noteEntry!.id }) + }) + await waitFor(() => expect(tauriMock.importClipboardScreenshot).toHaveBeenCalledTimes(1)) + unregister() + tauriMock.deleteAttachment.mockResolvedValueOnce(false) + await act(async () => { + imported.resolve({ id: 'attachment-cleanup-close', filename: 'cleanup.png' }) + await uploadPromise + }) + tauriMock.deleteAttachment.mockReturnValueOnce(cleanup.promise) + + const closeEvent = { preventDefault: vi.fn() } + let closePromise!: Promise + act(() => { closePromise = Promise.resolve(tauriWindowMock.closeRequestedHandler()!(closeEvent)) }) + await waitFor(() => expect(tauriMock.deleteAttachment).toHaveBeenCalledTimes(2)) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Edit during final cleanup'))) + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('Edit during final cleanup') + await act(async () => { cleanup.resolve(true) }) + + await waitFor(() => expect(tauriMock.updateEntry).toHaveBeenLastCalledWith( + 'entry-1', + expect.objectContaining({ body: '

Edit during final cleanup

' }), + )) + await waitFor(() => expect(tauriWindowMock.currentWindow.destroy).toHaveBeenCalledTimes(1)) + await closePromise + }) + it('shares one forced flush when Session switch and Tauri close overlap', async () => { const { result } = renderHook(() => useAppController()) @@ -206,61 +451,4 @@ describe('useAppController close protection', () => { expect(tauriWindowMock.currentWindow.destroy).not.toHaveBeenCalled() }) - it('keeps failed generation undo dirty so beforeunload can retry the save', async () => { - const { result } = renderHook(() => useAppController()) - - await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) - tauriMock.startAiActionJob.mockImplementationOnce(async (_request: unknown, onEvent: (event: unknown) => void) => { - onEvent({ - type: 'completed', - job_id: 'job-summary', - status: generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'completed' }), - result: { - generationContext: { id: 'context-1', sessionId: 'session-1', createdAt: '2026-06-24T10:00:00.000Z' }, - aiRun: { - id: 'run-1', - sessionId: 'session-1', - generationContextId: 'context-1', - provider: 'codex_cli', - model: 'default', - reasoningEffort: null, - promptVersion: 'summary-v1', - status: 'completed', - errorMessage: null, - createdAt: '2026-06-24T10:00:00.000Z', - completedAt: '2026-06-24T10:00:00.000Z', - }, - draft: null, - finding: null, - noteEntry: entryFixture({ body: '

Generated summary.

' }), - }, - }) - return { jobId: 'job-summary', status: generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'completed' }) } - }) - - await act(async () => { - await result.current.handleAiAction('summary') - }) - await waitFor(() => expect(result.current.notice).toBe('Summarizing note')) - await waitFor(() => expect(tauriMock.updateEntry).toHaveBeenCalledWith('entry-1', expect.objectContaining({ body: expect.stringContaining('Generated summary') }))) - - tauriMock.updateEntry.mockRejectedValueOnce(new Error('offline')) - await act(async () => { - await result.current.handleUndoLatestNoteGeneration() - }) - await waitFor(() => expect(result.current.error).toBeTruthy()) - - const event = new Event('beforeunload', { cancelable: true }) - await act(async () => { - window.dispatchEvent(event) - await Promise.resolve() - await Promise.resolve() - }) - - expect(event.defaultPrevented).toBe(true) - expect(tauriMock.updateEntry).toHaveBeenLastCalledWith( - 'entry-1', - expect.objectContaining({ body: expect.stringContaining('Checkout fails after payment') }), - ) - }) }) diff --git a/frontend/src/app/useAppController.derivedState.ts b/frontend/src/app/useAppController.derivedState.ts new file mode 100644 index 0000000..8ed7564 --- /dev/null +++ b/frontend/src/app/useAppController.derivedState.ts @@ -0,0 +1,96 @@ +import { useMemo } from 'react' +import type { Draft, Finding, GenerateAiActionKind, GenerationJobStatus, Session } from '../tauri' +import { + richEditorDocumentToPlainText, + type RichEditorDocument, +} from '../editor/editorDocument' +import { managedAttachmentReferencesForClipboard } from '../editor/clipboardExport' +import { countWords } from '../ui/format' +import type { PendingAiActions } from '../ui/types' +import { generationIsActive } from './generationActions' + +type PresentationStateOptions = { + drafts: Draft[] + findings: Finding[] + noteBody: RichEditorDocument + noteBodyHtml: string + searchQuery: string + sessionTitle: string + sessions: Session[] +} + +export function useAppControllerPresentationState({ + drafts, + findings, + noteBody, + noteBodyHtml, + searchQuery, + sessionTitle, + sessions, +}: PresentationStateOptions) { + const testwareDrafts = useMemo(() => drafts.filter((draft) => draft.kind === 'testware'), [drafts]) + const noteScreenshotCount = useMemo( + () => managedAttachmentReferencesForClipboard({ title: sessionTitle, bodyHtml: noteBodyHtml }).length, + [noteBodyHtml, sessionTitle], + ) + const draftScreenshotCounts = useMemo( + () => + Object.fromEntries( + testwareDrafts.map((draft) => [ + draft.id, + managedAttachmentReferencesForClipboard({ title: draft.title, bodyHtml: draft.body }).length, + ]), + ), + [testwareDrafts], + ) + const findingScreenshotCounts = useMemo( + () => + Object.fromEntries( + findings.map((finding) => [ + finding.id, + managedAttachmentReferencesForClipboard({ title: finding.title, bodyHtml: finding.body }).length, + ]), + ), + [findings], + ) + const filteredSessions = useMemo(() => { + const query = searchQuery.trim().toLocaleLowerCase() + if (!query) return sessions + return sessions.filter((session) => session.title.toLocaleLowerCase().includes(query)) + }, [sessions, searchQuery]) + const noteWordCount = useMemo(() => countWords(richEditorDocumentToPlainText(noteBody)), [noteBody]) + + return { + draftScreenshotCounts, + filteredSessions, + findingScreenshotCounts, + noteScreenshotCount, + noteWordCount, + testwareDrafts, + } +} + +type GenerationStateOptions = { + activeSession: Session | null + generationJobs: Record +} + +export function useAppControllerGenerationState({ activeSession, generationJobs }: GenerationStateOptions) { + const activeSessionJobs = useMemo( + () => Object.values(generationJobs).filter((job) => activeSession && job.sessionId === activeSession.id && generationIsActive(job)), + [generationJobs, activeSession], + ) + const pendingAiActions = useMemo(() => { + const pending: PendingAiActions = {} + // `job.action` is the backend's `GenerationJobStatus.action: String`, which + // is always a `GenerateAiActionKind` value. + for (const job of activeSessionJobs) pending[job.action as GenerateAiActionKind] = true + return pending + }, [activeSessionJobs]) + + return { + activeFindingJob: activeSessionJobs.find((job) => job.action === 'finding') ?? null, + activeTestwareJob: activeSessionJobs.find((job) => job.action === 'testware') ?? null, + pendingAiActions, + } +} diff --git a/frontend/src/app/useAppController.lifecycle.navigation-recovery.test.ts b/frontend/src/app/useAppController.lifecycle.navigation-recovery.test.ts new file mode 100644 index 0000000..971b347 --- /dev/null +++ b/frontend/src/app/useAppController.lifecycle.navigation-recovery.test.ts @@ -0,0 +1,163 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { entryFixture, generationStatusFixture, sessionFixture } from '../test/fixtures' +import { richEditorDocumentFromPlainText } from '../editor/editorDocument' +import { cleanupControllerTest, deferred, getTauriMock, sessionNoteStateFixture, setupControllerTest, useAppController } from './useAppController.testHarness' + +const tauriMock = getTauriMock() + +describe('useAppController recovery-gated navigation', () => { + beforeEach(setupControllerTest) + afterEach(cleanupControllerTest) + it('persists authored pre-recovery text before completing requested navigation', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'running' }), + ]) + tauriMock.getAiActionJobStatus.mockResolvedValue( + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'completed' }), + ) + tauriMock.openSessionNoteState + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Startup Note.

' }) })) + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Generated Summary.

' }) })) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Authored before navigation'))) + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + act(() => result.current.setActiveView('settings')) + expect(result.current.pendingNavigationView).toBe('settings') + + await act(async () => { await result.current.savePendingNavigationChanges() }) + + expect(tauriMock.updateEntry).toHaveBeenCalledWith( + 'entry-1', + expect.objectContaining({ body: expect.stringContaining('Authored before navigation') }), + ) + expect(result.current.activeView).toBe('settings') + expect(result.current.pendingNavigationView).toBeNull() + expect(result.current.pendingRecoveredSummaryDecision).toBe(false) + }) + + it('does not complete a pending navigation after its in-flight save is cancelled', async () => { + const titleSave = deferred>() + tauriMock.updateSession.mockReturnValueOnce(titleSave.promise) + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + + act(() => result.current.setSessionTitle('Title saved after cancellation')) + act(() => result.current.setActiveView('settings')) + expect(result.current.pendingNavigationView).toBe('settings') + + let savePromise!: Promise + act(() => { savePromise = result.current.savePendingNavigationChanges() }) + await waitFor(() => expect(tauriMock.updateSession).toHaveBeenCalledTimes(1)) + act(() => result.current.cancelPendingNavigation()) + act(() => titleSave.resolve(sessionFixture({ title: 'Title saved after cancellation' }))) + await act(async () => savePromise) + + expect(result.current.activeView).toBe('sessions') + expect(result.current.pendingNavigationView).toBeNull() + }) + + it('does not complete a pending navigation after its in-flight discard is cancelled', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'running' }), + ]) + tauriMock.getAiActionJobStatus.mockResolvedValue( + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'completed' }), + ) + tauriMock.openSessionNoteState + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Startup Note.

' }) })) + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Generated Summary.

' }) })) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Authored before recovery'))) + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Edited generated Summary'))) + act(() => result.current.setActiveView('settings')) + + const discardWrite = deferred>() + tauriMock.updateEntry.mockReturnValueOnce(discardWrite.promise) + let discardPromise!: Promise + act(() => { discardPromise = Promise.resolve(result.current.discardPendingNavigationChanges()) }) + await act(async () => { await Promise.resolve() }) + expect(tauriMock.updateEntry).toHaveBeenCalledTimes(1) + + act(() => result.current.cancelPendingNavigation()) + await act(async () => { + discardWrite.resolve(entryFixture({ body: '

Edited generated Summary

' })) + await discardPromise + }) + + expect(result.current.activeView).toBe('sessions') + expect(result.current.pendingNavigationView).toBeNull() + }) + + it('queues every cross-Session entry point behind the recovered Summary decision seam', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'running' }), + ]) + tauriMock.getAiActionJobStatus.mockResolvedValue( + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'completed' }), + ) + tauriMock.openSessionNoteState + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Startup Note.

' }) })) + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Generated Summary.

' }) })) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Authored before recovery'))) + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + expect(result.current.pendingRecoveredSummaryDecision).toBe(true) + + const otherSession = sessionFixture({ id: 'session-2', title: 'Other Session' }) + const otherState = sessionNoteStateFixture({ + session: otherSession, + noteEntry: entryFixture({ id: 'entry-2', sessionId: 'session-2' }), + }) + tauriMock.openSessionNoteState.mockClear() + tauriMock.openSessionNoteState.mockResolvedValue(otherState) + tauriMock.reopenSession.mockResolvedValue(otherSession) + + await act(async () => { await result.current.openSessionInCurrentView(otherSession) }) + expect(result.current.pendingNavigationView).toBe('sessions') + expect(tauriMock.openSessionNoteState).not.toHaveBeenCalled() + act(() => result.current.cancelPendingNavigation()) + + await act(async () => { await result.current.openSession(otherSession) }) + expect(result.current.pendingNavigationView).toBe('sessions') + expect(tauriMock.openSessionNoteState).not.toHaveBeenCalled() + act(() => result.current.cancelPendingNavigation()) + + await act(async () => { await result.current.handleNewSession() }) + expect(result.current.pendingNavigationView).toBe('sessions') + expect(tauriMock.createSession).not.toHaveBeenCalled() + act(() => result.current.cancelPendingNavigation()) + + await act(async () => { await result.current.openLibraryRecord('session-2', 'testware', 'draft-2') }) + expect(result.current.pendingNavigationView).toBe('testware') + expect(tauriMock.reopenSession).not.toHaveBeenCalled() + act(() => result.current.cancelPendingNavigation()) + + await act(async () => { + window.location.hash = '#/sessions/session-2/findings/finding-2' + window.dispatchEvent(new Event('hashchange')) + await Promise.resolve() + }) + expect(result.current.pendingNavigationView).toBe('findings') + expect(tauriMock.reopenSession).not.toHaveBeenCalled() + expect(result.current.activeSession?.id).toBe('session-1') + act(() => result.current.cancelPendingNavigation()) + expect(result.current.activeSession?.id).toBe('session-1') + + await act(async () => { await result.current.openSessionInCurrentView(otherSession) }) + expect(result.current.pendingNavigationView).toBe('sessions') + await act(async () => { await result.current.discardPendingNavigationChanges() }) + expect(result.current.activeSession?.id).toBe('session-2') + expect(result.current.pendingRecoveredSummaryDecision).toBe(false) + }) +}) diff --git a/frontend/src/app/useAppController.lifecycle.recovery-failures.test.ts b/frontend/src/app/useAppController.lifecycle.recovery-failures.test.ts new file mode 100644 index 0000000..8fbddd4 --- /dev/null +++ b/frontend/src/app/useAppController.lifecycle.recovery-failures.test.ts @@ -0,0 +1,154 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { entryFixture, generationStatusFixture, sessionFixture } from '../test/fixtures' +import { richEditorDocumentFromPlainText, richEditorDocumentToHtml, richEditorDocumentToPlainText } from '../editor/editorDocument' +import { cleanupControllerTest, deferred, getTauriMock, sessionNoteStateFixture, setupControllerTest, useAppController } from './useAppController.testHarness' + +const tauriMock = getTauriMock() + +describe('useAppController recovery failure protection', () => { + beforeEach(setupControllerTest) + afterEach(cleanupControllerTest) + it('keeps preserved images unsaved until their compensating write succeeds', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'running' }), + ]) + tauriMock.getAiActionJobStatus.mockResolvedValue( + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'completed' }), + ) + tauriMock.openSessionNoteState + .mockResolvedValueOnce(sessionNoteStateFixture({ + noteEntry: entryFixture({ + body: '

Original Note.

Evidence

', + }), + })) + .mockResolvedValueOnce(sessionNoteStateFixture({ + noteEntry: entryFixture({ body: '

Generated Summary.

' }), + })) + tauriMock.updateEntry.mockRejectedValueOnce(new Error('Temporary image persistence failure')) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + await act(async () => { await Promise.resolve() }) + + expect(richEditorDocumentToHtml(result.current.noteBody)).toContain('qa-scribe-attachment://attachment-1') + expect(result.current.sessionSaveState).toBe('unsaved') + expect(result.current.error).toContain('Temporary image persistence failure') + + let retried = false + await act(async () => { retried = await result.current.saveNoteNow() }) + expect(retried).toBe(true) + expect(result.current.sessionSaveState).toBe('saved') + }) + + it('keeps recovery fail-closed across a transient status error', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'running' }), + ]) + tauriMock.getAiActionJobStatus + .mockRejectedValueOnce(new Error('Temporary status failure')) + .mockResolvedValueOnce(generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'completed' })) + tauriMock.openSessionNoteState + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Startup Note.

' }) })) + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Canonical Summary.

' }) })) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('Startup Note.') + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Dirty during status retry'))) + expect(await result.current.saveNoteNow()).toBe(false) + + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + expect(tauriMock.getAiActionJobStatus).toHaveBeenCalledTimes(2) + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('Canonical Summary.') + }) + + it('keeps a completed Summary blocked until a transient canonical reload succeeds', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'running' }), + ]) + tauriMock.getAiActionJobStatus.mockResolvedValue( + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'completed' }), + ) + tauriMock.openSessionNoteState + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Startup Note.

' }) })) + .mockRejectedValueOnce(new Error('Temporary canonical reload failure')) + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Canonical Summary.

' }) })) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Dirty during canonical reload'))) + expect(await result.current.saveNoteNow()).toBe(false) + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('Dirty during canonical reload') + + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('Canonical Summary.') + }) + + it('does not hydrate an editable Note until transient active-job discovery succeeds', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs + .mockRejectedValueOnce(new Error('Temporary discovery failure')) + .mockResolvedValueOnce([]) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + expect(result.current.activeSession).toBeNull() + expect(result.current.noteEntry).toBeNull() + expect(tauriMock.openSessionNoteState).not.toHaveBeenCalled() + + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + expect(tauriMock.listActiveAiActionJobs).toHaveBeenCalledTimes(2) + expect(result.current.activeSession?.id).toBe('session-1') + expect(result.current.noteEntry?.id).toBe('entry-1') + }) + + it('retains completed Summary protection while its Session is opening in flight', async () => { + vi.useFakeTimers() + const sessionTwo = sessionFixture({ id: 'session-2', title: 'Other Session' }) + const staleOpen = deferred>() + let sessionTwoLoads = 0 + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-summary', sessionId: 'session-2', action: 'summary', state: 'running' }), + ]) + tauriMock.getAiActionJobStatus.mockResolvedValue( + generationStatusFixture({ jobId: 'job-summary', sessionId: 'session-2', action: 'summary', state: 'completed' }), + ) + tauriMock.openSessionNoteState.mockImplementation((sessionId: string) => { + if (sessionId === 'session-1') return Promise.resolve(sessionNoteStateFixture()) + sessionTwoLoads += 1 + if (sessionTwoLoads === 1) return staleOpen.promise + return Promise.resolve(sessionNoteStateFixture({ + session: sessionTwo, + noteEntry: entryFixture({ id: 'entry-2', sessionId: 'session-2', body: '

Canonical Summary.

' }), + })) + }) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + let openPromise!: Promise + act(() => { openPromise = result.current.openSession(sessionTwo) }) + await act(async () => { await Promise.resolve() }) + + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + expect(result.current.activeSession?.id).toBe('session-1') + staleOpen.resolve(sessionNoteStateFixture({ + session: sessionTwo, + noteEntry: entryFixture({ id: 'entry-2', sessionId: 'session-2', body: '

Stale Note.

' }), + })) + await act(async () => { await openPromise }) + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('Canonical Summary.') + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Dirty stale Session two edit'))) + expect(await result.current.saveNoteNow()).toBe(true) + expect(tauriMock.updateEntry).toHaveBeenCalledWith( + 'entry-2', + expect.objectContaining({ body: expect.stringContaining('Dirty stale Session two edit') }), + ) + }) +}) diff --git a/frontend/src/app/useAppController.lifecycle.summary-recovery.test.ts b/frontend/src/app/useAppController.lifecycle.summary-recovery.test.ts new file mode 100644 index 0000000..fd44059 --- /dev/null +++ b/frontend/src/app/useAppController.lifecycle.summary-recovery.test.ts @@ -0,0 +1,357 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { entryFixture, generationStatusFixture } from '../test/fixtures' +import { richEditorDocumentFromHtml, richEditorDocumentFromPlainText, richEditorDocumentToPlainText } from '../editor/editorDocument' +import { cleanupControllerTest, deferred, getTauriMock, sessionNoteStateFixture, setupControllerTest, useAppController } from './useAppController.testHarness' + +const tauriMock = getTauriMock() + +describe('useAppController recovered Summary decisions', () => { + beforeEach(setupControllerTest) + afterEach(cleanupControllerTest) + it('hydrates a stale Note only behind a recovered Summary block, then reloads the canonical completion', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'running' }), + ]) + tauriMock.getAiActionJobStatus.mockResolvedValue( + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'completed' }), + ) + tauriMock.openSessionNoteState + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Stale Note.

' }) })) + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Generated Summary.

' }) })) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('Stale Note.') + expect(tauriMock.getAiActionJobStatus).not.toHaveBeenCalled() + + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + + expect(tauriMock.openSessionNoteState).toHaveBeenCalledTimes(2) + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('Generated Summary.') + }) + + it('waits for recovered-job capture before startup hydration when the Summary completes first', async () => { + vi.useFakeTimers() + const activeJobs = deferred[]>() + tauriMock.listActiveAiActionJobs.mockReturnValue(activeJobs.promise) + tauriMock.openSessionNoteState.mockResolvedValue( + sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Already generated Summary.

' }) }), + ) + tauriMock.getAiActionJobStatus.mockResolvedValue( + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'completed' }), + ) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + expect(tauriMock.openSessionNoteState).not.toHaveBeenCalled() + + await act(async () => { + activeJobs.resolve([generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'running' })]) + await Promise.resolve() + await Promise.resolve() + }) + + expect(tauriMock.openSessionNoteState).toHaveBeenCalledTimes(1) + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('Already generated Summary.') + + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + expect(tauriMock.openSessionNoteState).toHaveBeenCalledTimes(2) + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('Already generated Summary.') + }) + + it('preserves dirty local Note edits as the recovered Summary undo value', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'running' }), + ]) + tauriMock.getAiActionJobStatus.mockResolvedValue( + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'completed' }), + ) + tauriMock.openSessionNoteState + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Stale Note.

' }) })) + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Generated Summary.

' }) })) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Dirty local edit'))) + + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('Generated Summary.') + expect(result.current.latestNoteGenerationUndo?.entryId).toBe('entry-1') + expect(richEditorDocumentToPlainText(result.current.latestNoteGenerationUndo!.before)).toBe('Dirty local edit') + expect(result.current.pendingRecoveredSummaryDecision).toBe(true) + expect(result.current.sessionSaveState).toBe('unsaved') + expect(tauriMock.updateEntry).not.toHaveBeenCalled() + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Edited generated Summary'))) + expect(result.current.pendingRecoveredSummaryDecision).toBe(true) + expect(richEditorDocumentToPlainText(result.current.latestNoteGenerationUndo!.before)).toBe('Dirty local edit') + expect(richEditorDocumentToPlainText(result.current.latestNoteGenerationUndo!.generated!)).toBe('Edited generated Summary') + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + expect(result.current.pendingRecoveredSummaryDecision).toBe(true) + expect(tauriMock.updateEntry).not.toHaveBeenCalled() + }) + + it('retries direct recovered Summary undo with the latest authored edit', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'running' }), + ]) + tauriMock.getAiActionJobStatus.mockResolvedValue( + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'completed' }), + ) + tauriMock.openSessionNoteState + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Stale Note.

' }) })) + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Generated Summary.

' }) })) + tauriMock.updateEntry + .mockRejectedValueOnce(new Error('undo offline')) + .mockResolvedValueOnce(entryFixture({ body: '

Authored after failed undo

' })) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Authored before recovery'))) + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + + await act(async () => { await result.current.handleUndoLatestNoteGeneration() }) + expect(result.current.latestNoteGenerationUndo?.pendingRecoveryChoice).toBe('authored') + expect(result.current.error).toContain('undo offline') + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Authored after failed undo'))) + await act(async () => { await result.current.handleUndoLatestNoteGeneration() }) + + expect(tauriMock.updateEntry).toHaveBeenLastCalledWith( + 'entry-1', + expect.objectContaining({ body: '

Authored after failed undo

' }), + ) + expect(result.current.pendingRecoveredSummaryDecision).toBe(false) + }) + + it('reuses materialized recovery images and cleans them when generated text wins', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'running' }), + ]) + tauriMock.getAiActionJobStatus.mockResolvedValue( + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'completed' }), + ) + tauriMock.openSessionNoteState + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Stale Note.

' }) })) + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Generated Summary.

' }) })) + tauriMock.importClipboardScreenshot.mockResolvedValue({ id: 'attachment-recovery-retry', filename: 'retry.png' }) + tauriMock.updateEntry + .mockRejectedValueOnce(new Error('first save offline')) + .mockRejectedValueOnce(new Error('second save offline')) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + act(() => result.current.setNoteBody(richEditorDocumentFromHtml( + '

Authored Evidence

Evidence', + ))) + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + + await act(async () => { await result.current.handleUndoLatestNoteGeneration() }) + await act(async () => { await result.current.handleUndoLatestNoteGeneration() }) + expect(tauriMock.importClipboardScreenshot).toHaveBeenCalledTimes(1) + expect(tauriMock.updateEntry).toHaveBeenLastCalledWith( + 'entry-1', + expect.objectContaining({ body: expect.stringContaining('attachment-recovery-retry') }), + ) + + await act(async () => { await result.current.discardPendingSessionEdits() }) + expect(tauriMock.deleteAttachment).toHaveBeenCalledWith('attachment-recovery-retry') + expect(result.current.pendingRecoveredSummaryDecision).toBe(false) + }) + + it('preserves edits from an earlier recovered Summary when a later Summary completes', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-summary-1', action: 'summary', state: 'running' }), + generationStatusFixture({ jobId: 'job-summary-2', action: 'summary', state: 'running' }), + ]) + let secondJobPolls = 0 + tauriMock.getAiActionJobStatus.mockImplementation(async (jobId: string) => { + if (jobId === 'job-summary-2') secondJobPolls += 1 + return generationStatusFixture({ + jobId, + action: 'summary', + state: jobId === 'job-summary-2' && secondJobPolls === 1 ? 'running' : 'completed', + }) + }) + const firstCompletion = deferred>() + const secondCompletion = deferred>() + tauriMock.openSessionNoteState + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Startup Note.

' }) })) + .mockReturnValueOnce(firstCompletion.promise) + .mockReturnValueOnce(secondCompletion.promise) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Authored before recoveries'))) + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + expect(tauriMock.openSessionNoteState).toHaveBeenCalledTimes(2) + await act(async () => { + firstCompletion.resolve(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

First recovered Summary.

' }) })) + await firstCompletion.promise + await Promise.resolve() + }) + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('First recovered Summary.') + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Edited first recovered Summary'))) + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + expect(tauriMock.openSessionNoteState).toHaveBeenCalledTimes(3) + await act(async () => { + secondCompletion.resolve(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Second recovered Summary.

' }) })) + await secondCompletion.promise + await Promise.resolve() + }) + + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('Second recovered Summary.') + expect(richEditorDocumentToPlainText(result.current.latestNoteGenerationUndo!.before)).toBe('Edited first recovered Summary') + expect(result.current.pendingRecoveredSummaryDecision).toBe(true) + }) + + it('ignores an older recovered Summary reload that resolves after a newer reload', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-summary-1', action: 'summary', state: 'running' }), + generationStatusFixture({ jobId: 'job-summary-2', action: 'summary', state: 'running' }), + ]) + tauriMock.getAiActionJobStatus.mockImplementation(async (jobId: string) => ( + generationStatusFixture({ jobId, action: 'summary', state: 'completed' }) + )) + const olderReload = deferred>() + const newerReload = deferred>() + tauriMock.openSessionNoteState + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Startup Note.

' }) })) + .mockReturnValueOnce(olderReload.promise) + .mockReturnValueOnce(newerReload.promise) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Authored before recoveries'))) + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + expect(tauriMock.openSessionNoteState).toHaveBeenCalledTimes(3) + + await act(async () => { + newerReload.resolve(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Newer recovered Summary.

' }) })) + await newerReload.promise + await Promise.resolve() + }) + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('Newer recovered Summary.') + + await act(async () => { + olderReload.resolve(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Older recovered Summary.

' }) })) + await olderReload.promise + await Promise.resolve() + }) + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('Newer recovered Summary.') + expect(richEditorDocumentToPlainText(result.current.latestNoteGenerationUndo!.generated!)).toBe('Newer recovered Summary.') + }) + + it('keeps a recovered Summary decision pending when an older authored write resolves after a generated-side edit', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'running' }), + ]) + tauriMock.getAiActionJobStatus.mockResolvedValue( + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'completed' }), + ) + tauriMock.openSessionNoteState + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Startup Note.

' }) })) + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Generated Summary.

' }) })) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Authored before recovery'))) + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + expect(result.current.pendingRecoveredSummaryDecision).toBe(true) + + const authoredWrite = deferred>() + tauriMock.updateEntry.mockReturnValueOnce(authoredWrite.promise) + let savePromise!: Promise + act(() => { savePromise = result.current.saveNoteNow() }) + await act(async () => { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + }) + expect(tauriMock.updateEntry).toHaveBeenCalledTimes(1) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Generated side edited later'))) + await act(async () => { + authoredWrite.resolve(entryFixture({ body: '

Authored before recovery

' })) + await savePromise + }) + + expect(tauriMock.updateEntry).toHaveBeenCalledTimes(1) + expect(result.current.pendingRecoveredSummaryDecision).toBe(true) + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('Generated side edited later') + expect(result.current.sessionSaveState).toBe('unsaved') + }) + + it('persists the authored pre-recovery Note when a recovered Summary decision is saved', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'running' }), + ]) + tauriMock.getAiActionJobStatus.mockResolvedValue( + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'completed' }), + ) + tauriMock.openSessionNoteState + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Startup Note.

' }) })) + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Generated Summary.

' }) })) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Authored before recovery'))) + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + + let saved = false + await act(async () => { saved = await result.current.saveNoteNow() }) + expect(saved).toBe(true) + expect(tauriMock.updateEntry).toHaveBeenCalledWith( + 'entry-1', + expect.objectContaining({ body: expect.stringContaining('Authored before recovery') }), + ) + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('Authored before recovery') + expect(result.current.pendingRecoveredSummaryDecision).toBe(false) + expect(result.current.sessionSaveState).toBe('saved') + }) + + it('keeps the generated Note when a recovered Summary decision is discarded', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'running' }), + ]) + tauriMock.getAiActionJobStatus.mockResolvedValue( + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'completed' }), + ) + tauriMock.openSessionNoteState + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Startup Note.

' }) })) + .mockResolvedValueOnce(sessionNoteStateFixture({ noteEntry: entryFixture({ body: '

Generated Summary.

' }) })) + + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Authored before recovery'))) + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Edited generated Summary'))) + + act(() => result.current.setActiveView('settings')) + expect(result.current.pendingNavigationView).toBe('settings') + await act(async () => { + await result.current.discardPendingNavigationChanges() + await Promise.resolve() + }) + + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('Edited generated Summary') + expect(result.current.pendingRecoveredSummaryDecision).toBe(false) + expect(result.current.sessionSaveState).toBe('saved') + expect(tauriMock.updateEntry).toHaveBeenCalledWith( + 'entry-1', + expect.objectContaining({ body: expect.stringContaining('Edited generated Summary') }), + ) + }) +}) diff --git a/frontend/src/app/useAppController.navigation.ts b/frontend/src/app/useAppController.navigation.ts new file mode 100644 index 0000000..764b5ec --- /dev/null +++ b/frontend/src/app/useAppController.navigation.ts @@ -0,0 +1,220 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react' +import type { Session } from '../tauri' +import { serializeRichEditorDocument } from '../editor/editorDocument' +import type { MainView } from '../ui/types' +import { navigationHash } from './navigationRoute' +import { createSessionNavigationActions } from './sessionNavigationActions' +import type { SessionWorkspace } from './types' +import type { useAttachmentActions } from './attachmentActions' +import type { useRecordActions } from './recordActions' +import type { useSessionActions } from './sessionActions' + +type AppControllerNavigationContext = { + activeSession: Session | null + activeView: MainView + attachmentActions: ReturnType + dirtyDraftIdsRef: MutableRefObject> + dirtyFindingIdsRef: MutableRefObject> + discardSettingsDraft: () => void + focusedRecordId: string | null + pendingNavigationContinuationRef: MutableRefObject<(() => void | Promise) | null> + pendingNavigationEpochRef: MutableRefObject + pendingNavigationView: MainView | null + pendingRecoveredSummaryDecision: boolean + recordActions: ReturnType + savedTitleRef: MutableRefObject + saveSettingsDraft: () => Promise + sessionActions: ReturnType + sessions: Session[] + sessionTitle: string + sessionWorkspace: SessionWorkspace + settingsDirty: boolean + settingsReturnViewRef: MutableRefObject + settingsSection: string | null + setActiveView: Dispatch> + setError: Dispatch> + setFocusedRecordId: Dispatch> + setPendingNavigationView: Dispatch> + setPendingSettingsSection: Dispatch> + setSettingsSection: Dispatch> +} + +export function useAppControllerNavigation(ctx: AppControllerNavigationContext) { + function requestActiveView(view: MainView) { + ctx.sessionActions.beginSessionNavigation() + if (view === ctx.activeView) return + const titlePending = Boolean(ctx.activeSession && ctx.sessionTitle !== ctx.savedTitleRef.current) + if ( + titlePending + || ctx.pendingRecoveredSummaryDecision + || ctx.settingsDirty + || ctx.dirtyDraftIdsRef.current.size > 0 + || ctx.dirtyFindingIdsRef.current.size > 0 + ) { + ctx.pendingNavigationEpochRef.current += 1 + ctx.pendingNavigationContinuationRef.current = () => ctx.setActiveView(view) + ctx.setPendingNavigationView(view) + return + } + ctx.setActiveView(view) + } + + function openSettingsSection(sectionId?: string) { + if (ctx.activeView !== 'settings') ctx.settingsReturnViewRef.current = ctx.activeView + const nextSection = sectionId ?? null + ctx.setSettingsSection(nextSection) + ctx.setPendingSettingsSection(nextSection) + requestActiveView('settings') + } + + function closeSettings() { requestActiveView(ctx.settingsReturnViewRef.current) } + + function requestSessionNavigation(view: MainView, navigate: () => Promise): Promise { + // A recovered Summary represents two valid canonical outcomes and must not + // be silently selected by the ordinary Session-switch flush. + if (ctx.pendingRecoveredSummaryDecision) { + ctx.pendingNavigationEpochRef.current += 1 + ctx.pendingNavigationContinuationRef.current = navigate + ctx.setPendingNavigationView(view) + return Promise.resolve() + } + return navigate() + } + + function requestOpenSession(session: Session, showNotice = true, onOpened?: () => void): Promise { + if (ctx.activeSession?.id === session.id) return Promise.resolve() + const destination = ctx.activeView === 'testware' || ctx.activeView === 'findings' ? ctx.activeView : 'sessions' + return requestSessionNavigation(destination, () => ctx.sessionActions.openSession(session, showNotice, onOpened)) + } + + function requestNewSession(): Promise { + return requestSessionNavigation('sessions', ctx.sessionActions.handleNewSession) + } + + const sessionNavigationActions = createSessionNavigationActions({ + activeSessionId: ctx.activeSession?.id ?? null, + activeView: ctx.activeView, + sessions: ctx.sessions, + settingsReturnViewRef: ctx.settingsReturnViewRef, + sessionActions: ctx.sessionActions, + requestActiveView, + requestSessionNavigation, + setActiveView: ctx.setActiveView, + setError: ctx.setError, + setFocusedRecordId: ctx.setFocusedRecordId, + setPendingSettingsSection: ctx.setPendingSettingsSection, + setSettingsSection: ctx.setSettingsSection, + }) + + async function savePendingNavigationChanges() { + const navigationEpoch = ctx.pendingNavigationEpochRef.current + const destination = ctx.pendingNavigationView + if (ctx.pendingRecoveredSummaryDecision) ctx.sessionActions.selectRecoveredSummaryChoice('authored') + const saved = await saveAllPendingChanges() + if (!saved || !destination || ctx.pendingNavigationEpochRef.current !== navigationEpoch) return + ctx.pendingNavigationEpochRef.current += 1 + const continuation = ctx.pendingNavigationContinuationRef.current + ctx.pendingNavigationContinuationRef.current = null + ctx.setPendingNavigationView(null) + if (continuation) await continuation() + else ctx.setActiveView(destination) + } + + function discardPendingNavigationChanges(): void | Promise { + const navigationEpoch = ctx.pendingNavigationEpochRef.current + const destination = ctx.pendingNavigationView + if (!destination) return + const finishDiscard = (discarded: boolean): void | Promise => { + if (!discarded || ctx.pendingNavigationEpochRef.current !== navigationEpoch) return + ctx.pendingNavigationEpochRef.current += 1 + ctx.recordActions.discardAllDirtyRecords() + if (ctx.dirtyDraftIdsRef.current.size > 0 || ctx.dirtyFindingIdsRef.current.size > 0) return + if (ctx.settingsDirty) ctx.discardSettingsDraft() + const continuation = ctx.pendingNavigationContinuationRef.current + ctx.pendingNavigationContinuationRef.current = null + ctx.setPendingNavigationView(null) + if (continuation) return Promise.resolve(continuation()).then(() => undefined) + ctx.setActiveView(destination) + } + const discarded = ctx.sessionActions.discardPendingSessionEdits() + return discarded instanceof Promise ? discarded.then(finishDiscard) : finishDiscard(discarded) + } + + function cancelPendingNavigation() { + ctx.pendingNavigationEpochRef.current += 1 + ctx.pendingNavigationContinuationRef.current = null + ctx.setPendingNavigationView(null) + window.history.replaceState(null, '', navigationHash({ + activeView: ctx.activeView, + sessionId: ctx.activeSession?.id ?? null, + focusedRecordId: ctx.focusedRecordId, + settingsSectionId: ctx.settingsSection, + })) + } + + async function saveAllPendingChanges(): Promise { + let sessionSaved = true + for (;;) { + await ctx.attachmentActions.waitForPendingAttachmentMutations() + const flushed = await ctx.sessionActions.savePendingSessionEdits() + sessionSaved = flushed && sessionSaved + await Promise.all([ + ctx.sessionActions.waitForPendingSessionWrites(), + ctx.recordActions.waitForPendingRecordWrites(), + ctx.attachmentActions.waitForPendingAttachmentMutations(), + ]) + const titleWriteVersion = ctx.sessionWorkspace.sessionTitleWriteVersionRef.current + const noteWriteVersion = ctx.sessionWorkspace.noteBodyWriteVersionRef.current + const sessionCompensated = await ctx.sessionActions.retryAllPendingSessionCompensations() + const recordsCompensated = await ctx.recordActions.retryAllPendingRecordCompensations() + const attachmentsCleaned = await ctx.attachmentActions.retryPendingAttachmentCleanup() + if (!sessionCompensated || !recordsCompensated || !attachmentsCleaned) return false + if (!flushed) return false + await Promise.all([ + ctx.sessionActions.waitForPendingSessionWrites(), + ctx.recordActions.waitForPendingRecordWrites(), + ctx.attachmentActions.waitForPendingAttachmentMutations(), + ]) + const sessionRefsDirty = Boolean( + ctx.sessionWorkspace.activeSessionIdRef.current + && ( + ctx.sessionWorkspace.sessionTitleRef.current !== ctx.sessionWorkspace.savedTitleRef.current + || ( + ctx.sessionWorkspace.noteEntryIdRef.current + && serializeRichEditorDocument(ctx.sessionWorkspace.noteBodyRef.current) !== ctx.sessionWorkspace.savedBodyRef.current + ) + ), + ) + if ( + titleWriteVersion === ctx.sessionWorkspace.sessionTitleWriteVersionRef.current + && noteWriteVersion === ctx.sessionWorkspace.noteBodyWriteVersionRef.current + && !sessionRefsDirty + && !ctx.attachmentActions.hasPendingAttachmentOperations() + && !ctx.sessionActions.hasPendingSessionEdits() + && !hasPendingCompensations() + ) break + } + const settingsSaved = !ctx.settingsDirty || await ctx.saveSettingsDraft() + return sessionSaved && settingsSaved + } + + function hasPendingCompensations() { + return ctx.sessionActions.hasPendingSessionCompensations() + || ctx.recordActions.hasPendingRecordCompensations() + || ctx.attachmentActions.hasPendingAttachmentMutations() + } + + return { + ...sessionNavigationActions, + cancelPendingNavigation, + closeSettings, + discardPendingNavigationChanges, + hasPendingCompensations, + openSettingsSection, + requestActiveView, + requestNewSession, + requestOpenSession, + saveAllPendingChanges, + savePendingNavigationChanges, + } +} diff --git a/frontend/src/app/useAppController.sessionIntegrity.compensation.test.ts b/frontend/src/app/useAppController.sessionIntegrity.compensation.test.ts new file mode 100644 index 0000000..22f9280 --- /dev/null +++ b/frontend/src/app/useAppController.sessionIntegrity.compensation.test.ts @@ -0,0 +1,314 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { draftFixture, entryFixture, findingFixture, sessionFixture } from '../test/fixtures' +import { richEditorDocumentFromPlainText, richEditorDocumentToPlainText } from '../editor/editorDocument' +import { + cleanupControllerTest, + deferred, + getTauriMock, + getTauriWindowMock, + sessionNoteStateFixture, + setupControllerTest, + useAppController, +} from './useAppController.testHarness' + +const tauriMock = getTauriMock() +const tauriWindowMock = getTauriWindowMock() + +describe('useAppController Session integrity', () => { + beforeEach(setupControllerTest) + afterEach(cleanupControllerTest) + + it('restores the saved backend title when discard supersedes an in-flight title save', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + const staleSave = deferred>() + tauriMock.updateSession.mockReturnValueOnce(staleSave.promise) + + act(() => result.current.setSessionTitle('Stale renamed Session')) + let savePromise!: Promise + act(() => { savePromise = result.current.saveNoteNow() }) + await waitFor(() => expect(tauriMock.updateSession).toHaveBeenCalledTimes(1)) + await act(async () => { await result.current.discardPendingSessionEdits() }) + + await act(async () => { + staleSave.resolve(sessionFixture({ title: 'Stale renamed Session' })) + await savePromise + }) + + expect(tauriMock.updateSession).toHaveBeenCalledTimes(2) + expect(tauriMock.updateSession).toHaveBeenLastCalledWith('session-1', { title: 'Checkout session' }) + expect(result.current.sessionTitle).toBe('Checkout session') + expect(result.current.sessionSaveState).toBe('saved') + }) + + it('restores dirty title state when stale-write compensation rejects', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + const staleSave = deferred>() + tauriMock.updateSession + .mockReturnValueOnce(staleSave.promise) + .mockRejectedValueOnce(new Error('title compensation offline')) + + act(() => result.current.setSessionTitle('Stale renamed Session')) + let savePromise!: Promise + act(() => { savePromise = result.current.saveNoteNow() }) + await waitFor(() => expect(tauriMock.updateSession).toHaveBeenCalledTimes(1)) + await act(async () => { await result.current.discardPendingSessionEdits() }) + await act(async () => { + staleSave.resolve(sessionFixture({ title: 'Stale renamed Session' })) + await savePromise + }) + + expect(result.current.sessionTitle).toBe('Checkout session') + expect(result.current.sessionSaveState).toBe('unsaved') + expect(result.current.error).toContain('title compensation offline') + await act(async () => { expect(await result.current.saveNoteNow()).toBe(true) }) + expect(result.current.sessionSaveState).toBe('saved') + }) + + it('restores dirty Note state when stale-write compensation rejects', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + const staleSave = deferred>() + tauriMock.updateEntry + .mockReturnValueOnce(staleSave.promise) + .mockRejectedValueOnce(new Error('Note compensation offline')) + + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Stale Note edit'))) + let savePromise!: Promise + act(() => { savePromise = result.current.saveNoteNow() }) + await waitFor(() => expect(tauriMock.updateEntry).toHaveBeenCalledTimes(1)) + await act(async () => { await result.current.discardPendingSessionEdits() }) + await act(async () => { + staleSave.resolve(entryFixture({ body: '

Stale Note edit

' })) + await savePromise + }) + + expect(result.current.sessionSaveState).toBe('unsaved') + expect(result.current.error).toContain('Note compensation offline') + await act(async () => { expect(await result.current.saveNoteNow()).toBe(true) }) + expect(result.current.sessionSaveState).toBe('saved') + }) + + it('restores the newest saved title when an older response lands after navigation', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + const olderSave = deferred>() + tauriMock.updateSession + .mockReturnValueOnce(olderSave.promise) + .mockResolvedValueOnce(sessionFixture({ title: 'Newest saved title' })) + .mockResolvedValueOnce(sessionFixture({ title: 'Newest saved title' })) + + act(() => result.current.setSessionTitle('Older title')) + let olderPromise!: Promise + act(() => { olderPromise = result.current.saveTitle('Older title') }) + await waitFor(() => expect(tauriMock.updateSession).toHaveBeenCalledTimes(1)) + act(() => result.current.setSessionTitle('Newest saved title')) + await act(async () => { expect(await result.current.saveTitle('Newest saved title')).toBe(true) }) + + const sessionTwo = sessionFixture({ id: 'session-2', title: 'Session Two' }) + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ + session: sessionTwo, + noteEntry: entryFixture({ id: 'entry-2', sessionId: 'session-2' }), + })) + await act(async () => { await result.current.openSession(sessionTwo) }) + await act(async () => { + olderSave.resolve(sessionFixture({ title: 'Older title' })) + await olderPromise + }) + + expect(result.current.activeSession?.id).toBe('session-2') + expect(tauriMock.updateSession).toHaveBeenLastCalledWith('session-1', { title: 'Newest saved title' }) + expect(result.current.sessions.find((session) => session.id === 'session-1')?.title).toBe('Newest saved title') + }) + + it('retries failed inactive-Session title compensation before opening that Session', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + const olderSave = deferred>() + tauriMock.updateSession + .mockReturnValueOnce(olderSave.promise) + .mockResolvedValueOnce(sessionFixture({ title: 'Newest saved title' })) + .mockRejectedValueOnce(new Error('inactive compensation offline')) + .mockResolvedValueOnce(sessionFixture({ title: 'Newest saved title' })) + + act(() => result.current.setSessionTitle('Older title')) + let olderPromise!: Promise + act(() => { olderPromise = result.current.saveTitle('Older title') }) + await waitFor(() => expect(tauriMock.updateSession).toHaveBeenCalledTimes(1)) + act(() => result.current.setSessionTitle('Newest saved title')) + await act(async () => { await result.current.saveTitle('Newest saved title') }) + + const sessionTwo = sessionFixture({ id: 'session-2', title: 'Session Two' }) + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ + session: sessionTwo, + noteEntry: entryFixture({ id: 'entry-2', sessionId: 'session-2' }), + })) + await act(async () => { await result.current.openSession(sessionTwo) }) + await act(async () => { + olderSave.resolve(sessionFixture({ title: 'Older title' })) + await olderPromise + }) + expect(result.current.error).toContain('inactive compensation offline') + + const sessionOne = sessionFixture({ title: 'Older title' }) + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ session: sessionFixture({ title: 'Newest saved title' }) })) + await act(async () => { await result.current.openSession(sessionOne) }) + + expect(tauriMock.updateSession).toHaveBeenLastCalledWith('session-1', { title: 'Newest saved title' }) + expect(tauriMock.updateSession).toHaveBeenCalledTimes(4) + expect(result.current.activeSession?.id).toBe('session-1') + expect(result.current.sessionTitle).toBe('Newest saved title') + }) + + it('retries failed inactive-Session Note compensation before opening that Session', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + const sessionOne = result.current.activeSession! + const olderWrite = deferred>() + tauriMock.updateEntry + .mockReturnValueOnce(olderWrite.promise) + .mockResolvedValueOnce(entryFixture({ body: '

Newest Note body

' })) + .mockRejectedValueOnce(new Error('inactive Note compensation offline')) + .mockRejectedValueOnce(new Error('active Session save offline')) + .mockResolvedValueOnce(entryFixture({ body: '

Newest Note body

' })) + .mockResolvedValueOnce(entryFixture({ id: 'entry-2', sessionId: 'session-2', body: '

Unsaved Session Two

' })) + + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Older Note body'))) + let olderPromise!: Promise + act(() => { olderPromise = result.current.saveNoteNow() }) + await waitFor(() => expect(tauriMock.updateEntry).toHaveBeenCalledTimes(1)) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Newest Note body'))) + await act(async () => { expect(await result.current.saveNoteNow()).toBe(true) }) + + const sessionTwo = sessionFixture({ id: 'session-2', title: 'Session Two' }) + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ + session: sessionTwo, + noteEntry: entryFixture({ id: 'entry-2', sessionId: 'session-2' }), + })) + await act(async () => { await result.current.openSession(sessionTwo) }) + await act(async () => { + olderWrite.resolve(entryFixture({ body: '

Older Note body

' })) + await olderPromise + }) + expect(result.current.activeSession?.id).toBe('session-2') + expect(result.current.error).toContain('inactive Note compensation offline') + + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Unsaved Session Two'))) + const closeEvent = { preventDefault: vi.fn() } + await act(async () => { await tauriWindowMock.closeRequestedHandler()?.(closeEvent) }) + expect(closeEvent.preventDefault).toHaveBeenCalled() + expect(tauriMock.updateEntry).toHaveBeenCalledTimes(5) + expect(tauriMock.updateEntry).toHaveBeenNthCalledWith( + 5, + 'entry-1', + expect.objectContaining({ body: '

Newest Note body

' }), + ) + expect(tauriWindowMock.currentWindow.destroy).not.toHaveBeenCalled() + + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ + session: sessionOne, + noteEntry: entryFixture({ body: '

Newest Note body

' }), + })) + await act(async () => { await result.current.openSession(sessionOne) }) + + expect(tauriMock.updateEntry).toHaveBeenCalledTimes(6) + expect(result.current.activeSession?.id).toBe('session-1') + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('Newest Note body') + }) + + it('keeps failed inactive Record compensation out of the current workspace and retries it on reopen', async () => { + const draft = draftFixture({ id: 'draft-1', sessionId: 'session-1', body: '

Saved Draft

' }) + const finding = findingFixture({ id: 'finding-1', sessionId: 'session-1', body: '

Saved Finding

' }) + tauriMock.listDrafts.mockResolvedValueOnce([draft]) + tauriMock.listFindings.mockResolvedValueOnce([finding]) + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + const sessionOne = result.current.activeSession! + act(() => result.current.setActiveView('testware')) + await waitFor(() => expect(result.current.testwareDrafts).toEqual([draft])) + act(() => result.current.setActiveView('findings')) + await waitFor(() => expect(result.current.findings).toEqual([finding])) + + act(() => { + result.current.updateLocalDraft(draft.id, { body: '

Older Draft

' }) + result.current.updateLocalFinding(finding.id, { body: '

Older Finding

' }) + }) + const olderDraftWrite = deferred>() + const olderFindingWrite = deferred>() + tauriMock.updateDraft + .mockReturnValueOnce(olderDraftWrite.promise) + .mockResolvedValueOnce(draftFixture({ id: draft.id, body: '

Newest Draft

' })) + .mockRejectedValueOnce(new Error('inactive Draft compensation offline')) + .mockRejectedValueOnce(new Error('close Draft retry offline')) + .mockResolvedValueOnce(draftFixture({ id: draft.id, body: '

Newest Draft

' })) + tauriMock.updateFinding + .mockReturnValueOnce(olderFindingWrite.promise) + .mockResolvedValueOnce(findingFixture({ id: finding.id, body: '

Newest Finding

' })) + .mockRejectedValueOnce(new Error('inactive Finding compensation offline')) + .mockRejectedValueOnce(new Error('close Finding retry offline')) + .mockResolvedValueOnce(findingFixture({ id: finding.id, body: '

Newest Finding

' })) + + let olderDraftPromise!: Promise + let olderFindingPromise!: Promise + act(() => { + olderDraftPromise = result.current.handleSaveDraft(result.current.testwareDrafts[0]) + olderFindingPromise = result.current.handleSaveFinding(result.current.findings[0]) + }) + await waitFor(() => { + expect(tauriMock.updateDraft).toHaveBeenCalledTimes(1) + expect(tauriMock.updateFinding).toHaveBeenCalledTimes(1) + }) + act(() => { + result.current.updateLocalDraft(draft.id, { body: '

Newest Draft

' }) + result.current.updateLocalFinding(finding.id, { body: '

Newest Finding

' }) + }) + await act(async () => { + expect(await result.current.handleSaveDraft(result.current.testwareDrafts[0])).toBe(true) + expect(await result.current.handleSaveFinding(result.current.findings[0])).toBe(true) + }) + + const sessionTwo = sessionFixture({ id: 'session-2', title: 'Session Two' }) + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ + session: sessionTwo, + noteEntry: entryFixture({ id: 'entry-2', sessionId: 'session-2' }), + })) + await act(async () => { await result.current.openSession(sessionTwo) }) + await act(async () => { + olderDraftWrite.resolve(draftFixture({ id: draft.id, body: '

Older Draft

' })) + olderFindingWrite.resolve(findingFixture({ id: finding.id, body: '

Older Finding

' })) + await Promise.all([olderDraftPromise, olderFindingPromise]) + }) + + expect(result.current.activeSession?.id).toBe('session-2') + expect(result.current.testwareDrafts).toEqual([]) + expect(result.current.findings).toEqual([]) + + const closeEvent = { preventDefault: vi.fn() } + await act(async () => { await tauriWindowMock.closeRequestedHandler()?.(closeEvent) }) + expect(closeEvent.preventDefault).toHaveBeenCalled() + expect(tauriMock.updateDraft).toHaveBeenCalledTimes(4) + expect(tauriMock.updateFinding).toHaveBeenCalledTimes(4) + expect(tauriWindowMock.currentWindow.destroy).not.toHaveBeenCalled() + + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ + session: sessionOne, + testwareDraftCount: 1, + findingCount: 1, + })) + await act(async () => { await result.current.openSession(sessionOne) }) + + expect(tauriMock.updateDraft).toHaveBeenCalledTimes(5) + expect(tauriMock.updateFinding).toHaveBeenCalledTimes(5) + expect(tauriMock.updateDraft).toHaveBeenLastCalledWith( + draft.id, + expect.objectContaining({ body: '

Newest Draft

' }), + ) + expect(tauriMock.updateFinding).toHaveBeenLastCalledWith( + finding.id, + expect.objectContaining({ body: '

Newest Finding

' }), + ) + expect(result.current.activeSession?.id).toBe('session-1') + }) +}) diff --git a/frontend/src/app/useAppController.sessionIntegrity.navigation.test.ts b/frontend/src/app/useAppController.sessionIntegrity.navigation.test.ts new file mode 100644 index 0000000..746873f --- /dev/null +++ b/frontend/src/app/useAppController.sessionIntegrity.navigation.test.ts @@ -0,0 +1,359 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { draftFixture, entryFixture, findingFixture, sessionFixture } from '../test/fixtures' +import { richEditorDocumentFromPlainText, richEditorDocumentToPlainText } from '../editor/editorDocument' +import { + cleanupControllerTest, + deferred, + getTauriMock, + sessionNoteStateFixture, + setupControllerTest, + useAppController, +} from './useAppController.testHarness' + +const tauriMock = getTauriMock() + +describe('useAppController Session integrity', () => { + beforeEach(setupControllerTest) + afterEach(cleanupControllerTest) + + it('lets only the latest reversed Session-open response update workspace and busy state', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + const sessionTwo = sessionFixture({ id: 'session-2', title: 'Session Two' }) + const sessionThree = sessionFixture({ id: 'session-3', title: 'Session Three' }) + const openTwo = deferred>() + const openThree = deferred>() + tauriMock.openSessionNoteState.mockImplementation((sessionId: string) => ( + sessionId === 'session-2' ? openTwo.promise : openThree.promise + )) + + let firstOpen!: Promise + let latestOpen!: Promise + act(() => { firstOpen = result.current.openSession(sessionTwo) }) + await waitFor(() => expect(tauriMock.openSessionNoteState).toHaveBeenCalledWith('session-2')) + act(() => { latestOpen = result.current.openSession(sessionThree) }) + await waitFor(() => expect(tauriMock.openSessionNoteState).toHaveBeenCalledWith('session-3')) + + await act(async () => { + openThree.resolve(sessionNoteStateFixture({ + session: sessionThree, + noteEntry: entryFixture({ id: 'entry-3', sessionId: 'session-3' }), + })) + await latestOpen + }) + expect(result.current.activeSession?.id).toBe('session-3') + expect(result.current.busyAction).toBeNull() + + await act(async () => { + openTwo.resolve(sessionNoteStateFixture({ + session: sessionTwo, + noteEntry: entryFixture({ id: 'entry-2', sessionId: 'session-2' }), + })) + await firstOpen + }) + expect(result.current.activeSession?.id).toBe('session-3') + expect(result.current.noteEntry?.id).toBe('entry-3') + }) + + it('lets reselecting the active Session cancel a pending Session open', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + const activeSession = result.current.activeSession! + const sessionTwo = sessionFixture({ id: 'session-2', title: 'Session Two' }) + const openTwo = deferred>() + tauriMock.openSessionNoteState.mockReturnValueOnce(openTwo.promise) + + let pendingOpen!: Promise + act(() => { pendingOpen = result.current.openSessionInCurrentView(sessionTwo) }) + await waitFor(() => expect(tauriMock.openSessionNoteState).toHaveBeenCalledWith('session-2')) + expect(result.current.busyAction).toBe('open-session') + + await act(async () => { await result.current.openSessionInCurrentView(activeSession) }) + expect(result.current.busyAction).toBeNull() + + await act(async () => { + openTwo.resolve(sessionNoteStateFixture({ + session: sessionTwo, + noteEntry: entryFixture({ id: 'entry-2', sessionId: 'session-2' }), + })) + await pendingOpen + }) + + expect(result.current.activeSession?.id).toBe('session-1') + expect(result.current.noteEntry?.id).toBe('entry-1') + }) + + it('does not let a stale open release the latest operation busy state', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + const sessionTwo = sessionFixture({ id: 'session-2' }) + const sessionThree = sessionFixture({ id: 'session-3' }) + const openTwo = deferred>() + const openThree = deferred>() + tauriMock.openSessionNoteState.mockImplementation((sessionId: string) => ( + sessionId === 'session-2' ? openTwo.promise : openThree.promise + )) + + let firstOpen!: Promise + let latestOpen!: Promise + act(() => { firstOpen = result.current.openSession(sessionTwo) }) + await waitFor(() => expect(tauriMock.openSessionNoteState).toHaveBeenCalledWith('session-2')) + act(() => { latestOpen = result.current.openSession(sessionThree) }) + await waitFor(() => expect(tauriMock.openSessionNoteState).toHaveBeenCalledWith('session-3')) + + await act(async () => { + openTwo.resolve(sessionNoteStateFixture({ session: sessionTwo })) + await firstOpen + }) + expect(result.current.busyAction).toBe('open-session') + expect(result.current.activeSession?.id).toBe('session-1') + + await act(async () => { + openThree.resolve(sessionNoteStateFixture({ session: sessionThree })) + await latestOpen + }) + expect(result.current.busyAction).toBeNull() + expect(result.current.activeSession?.id).toBe('session-3') + }) + + it('lets an immediate library intent cancel an async open and release its busy state', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + const sessionTwo = sessionFixture({ id: 'session-2', title: 'Slow Session' }) + const slowOpen = deferred>() + tauriMock.openSessionNoteState.mockReturnValueOnce(slowOpen.promise) + + let openPromise!: Promise + act(() => { openPromise = result.current.openSession(sessionTwo) }) + await waitFor(() => expect(result.current.busyAction).toBe('open-session')) + await waitFor(() => expect(tauriMock.openSessionNoteState).toHaveBeenCalledWith('session-2')) + + act(() => result.current.setActiveView('testware-library')) + expect(result.current.activeView).toBe('testware-library') + expect(result.current.busyAction).toBeNull() + + await act(async () => { + slowOpen.resolve(sessionNoteStateFixture({ + session: sessionTwo, + noteEntry: entryFixture({ id: 'entry-2', sessionId: 'session-2' }), + })) + await openPromise + }) + expect(result.current.activeSession?.id).toBe('session-1') + expect(result.current.activeView).toBe('testware-library') + expect(result.current.busyAction).toBeNull() + }) + + it('publishes a created Session but does not replace newer navigation while its Note creation is superseded', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + const createdSession = sessionFixture({ id: 'session-new', title: 'Untitled session 2' }) + const noteCreation = deferred>() + tauriMock.createSession.mockResolvedValueOnce(createdSession) + tauriMock.createEntry.mockReturnValueOnce(noteCreation.promise) + + let newSessionPromise!: Promise + act(() => { newSessionPromise = result.current.handleNewSession() }) + await waitFor(() => expect(tauriMock.createEntry).toHaveBeenCalled()) + expect(result.current.busyAction).toBe('new-session') + + act(() => result.current.setActiveView('testware-library')) + expect(result.current.busyAction).toBeNull() + expect(result.current.activeView).toBe('testware-library') + await act(async () => { + noteCreation.resolve(entryFixture({ id: 'entry-new', sessionId: 'session-new' })) + await newSessionPromise + }) + + expect(result.current.sessions.map((session) => session.id)).toContain('session-new') + expect(result.current.activeSession?.id).toBe('session-1') + expect(result.current.activeView).toBe('testware-library') + expect(result.current.busyAction).toBeNull() + }) + + it('keeps title and Note edits made while a Session open is awaiting the backend', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + const sessionTwo = sessionFixture({ id: 'session-2', title: 'Session Two' }) + const delayedOpen = deferred>() + tauriMock.openSessionNoteState.mockReturnValueOnce(delayedOpen.promise) + + let openPromise!: Promise + act(() => { openPromise = result.current.openSession(sessionTwo) }) + await waitFor(() => expect(tauriMock.openSessionNoteState).toHaveBeenCalledWith('session-2')) + act(() => { + result.current.setSessionTitle('Authored during open') + result.current.setNoteBody(richEditorDocumentFromPlainText('Note authored during open')) + }) + await act(async () => { + delayedOpen.resolve(sessionNoteStateFixture({ + session: sessionTwo, + noteEntry: entryFixture({ id: 'entry-2', sessionId: 'session-2' }), + })) + await openPromise + }) + + expect(result.current.activeSession?.id).toBe('session-1') + expect(result.current.sessionTitle).toBe('Authored during open') + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('Note authored during open') + expect(result.current.sessionSaveState).toBe('unsaved') + }) + + it('publishes a durable new Session while preserving edits made during delayed creation', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + const delayedCreation = deferred>() + tauriMock.createSession.mockReturnValueOnce(delayedCreation.promise) + + let creationPromise!: Promise + act(() => { creationPromise = result.current.handleNewSession() }) + await waitFor(() => expect(tauriMock.createSession).toHaveBeenCalledTimes(1)) + act(() => { + result.current.setSessionTitle('Authored during creation') + result.current.setNoteBody(richEditorDocumentFromPlainText('Note authored during creation')) + }) + await act(async () => { + delayedCreation.resolve(sessionFixture({ id: 'session-new', title: 'Untitled session 2' })) + await creationPromise + }) + + expect(result.current.sessions.map((session) => session.id)).toContain('session-new') + expect(tauriMock.createEntry).not.toHaveBeenCalled() + expect(result.current.activeSession?.id).toBe('session-1') + expect(result.current.sessionTitle).toBe('Authored during creation') + expect(richEditorDocumentToPlainText(result.current.noteBody)).toBe('Note authored during creation') + expect(result.current.sessionSaveState).toBe('unsaved') + }) + + it('keeps a Draft edit made while a Session open is awaiting the backend', async () => { + const draft = draftFixture({ id: 'draft-1', sessionId: 'session-1', body: '

Saved draft.

' }) + tauriMock.listDrafts.mockResolvedValueOnce([draft]) + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('testware')) + await waitFor(() => expect(result.current.testwareDrafts).toEqual([draft])) + + const sessionTwo = sessionFixture({ id: 'session-2', title: 'Session Two' }) + const delayedOpen = deferred>() + tauriMock.openSessionNoteState.mockReturnValueOnce(delayedOpen.promise) + let openPromise!: Promise + act(() => { openPromise = result.current.openSession(sessionTwo) }) + await waitFor(() => expect(tauriMock.openSessionNoteState).toHaveBeenCalledWith('session-2')) + + act(() => result.current.updateLocalDraft(draft.id, { body: '

Authored during open.

' })) + await act(async () => { + delayedOpen.resolve(sessionNoteStateFixture({ + session: sessionTwo, + noteEntry: entryFixture({ id: 'entry-2', sessionId: 'session-2' }), + })) + await openPromise + }) + + expect(result.current.activeSession?.id).toBe('session-1') + expect(result.current.testwareDrafts[0]?.body).toBe('

Authored during open.

') + expect(result.current.busyAction).toBeNull() + }) + + it('publishes a durable new Session while preserving a Finding edit made during delayed creation', async () => { + const finding = findingFixture({ id: 'finding-1', sessionId: 'session-1', body: '

Saved finding.

' }) + tauriMock.listFindings.mockResolvedValueOnce([finding]) + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('findings')) + await waitFor(() => expect(result.current.findings).toEqual([finding])) + + const delayedCreation = deferred>() + tauriMock.createSession.mockReturnValueOnce(delayedCreation.promise) + let creationPromise!: Promise + act(() => { creationPromise = result.current.handleNewSession() }) + await waitFor(() => expect(tauriMock.createSession).toHaveBeenCalledTimes(1)) + + act(() => result.current.updateLocalFinding(finding.id, { body: '

Authored during creation.

' })) + await act(async () => { + delayedCreation.resolve(sessionFixture({ id: 'session-new', title: 'Untitled session 2' })) + await creationPromise + }) + + expect(result.current.sessions.map((session) => session.id)).toContain('session-new') + expect(result.current.activeSession?.id).toBe('session-1') + expect(result.current.findings[0]?.body).toBe('

Authored during creation.

') + expect(result.current.busyAction).toBeNull() + }) + + it('cancels an older cross-Session library intent before its reopen resolves', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + const staleReopen = deferred>() + tauriMock.reopenSession.mockReturnValueOnce(staleReopen.promise) + const sessionThree = sessionFixture({ id: 'session-3', title: 'Latest Session' }) + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ + session: sessionThree, + noteEntry: entryFixture({ id: 'entry-3', sessionId: 'session-3' }), + })) + + let staleIntent!: Promise + act(() => { staleIntent = result.current.openLibraryRecord('session-2', 'testware', 'draft-2') }) + await waitFor(() => expect(tauriMock.reopenSession).toHaveBeenCalledWith('session-2')) + await act(async () => { await result.current.openSession(sessionThree) }) + await act(async () => { + staleReopen.resolve(sessionFixture({ id: 'session-2', title: 'Stale Session' })) + await staleIntent + }) + + expect(result.current.activeSession?.id).toBe('session-3') + expect(result.current.focusedRecordId).toBeNull() + expect(tauriMock.openSessionNoteState).not.toHaveBeenCalledWith('session-2') + }) + + it('keeps the latest output-library result and ignores an older rejection', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + let rejectOlderDraftLoad: (cause: Error) => void = () => {} + const olderDraftLoad = new Promise((_resolve, reject) => { rejectOlderDraftLoad = reject }) + const latestDraftLoad = deferred; sessionTitle: string }>>() + tauriMock.listDraftLibrary + .mockReturnValueOnce(olderDraftLoad) + .mockReturnValueOnce(latestDraftLoad.promise) + + let olderRequest!: Promise + let latestRequest!: Promise + act(() => { + olderRequest = result.current.loadDraftLibrary() + latestRequest = result.current.loadDraftLibrary() + }) + await act(async () => { + latestDraftLoad.resolve([{ draft: draftFixture({ id: 'draft-latest' }), sessionTitle: 'Latest' }]) + await latestRequest + }) + expect(result.current.draftLibrary.map((item) => item.draft.id)).toEqual(['draft-latest']) + expect(result.current.draftLibraryState).toBe('ready') + + await act(async () => { + rejectOlderDraftLoad(new Error('stale failure')) + await olderRequest + }) + expect(result.current.draftLibrary.map((item) => item.draft.id)).toEqual(['draft-latest']) + expect(result.current.draftLibraryError).toBeNull() + expect(result.current.draftLibraryState).toBe('ready') + + const olderFindingLoad = deferred; sessionTitle: string }>>() + const latestFindingLoad = deferred; sessionTitle: string }>>() + tauriMock.listFindingLibrary + .mockReturnValueOnce(olderFindingLoad.promise) + .mockReturnValueOnce(latestFindingLoad.promise) + let olderFindingRequest!: Promise + let latestFindingRequest!: Promise + act(() => { + olderFindingRequest = result.current.loadFindingLibrary() + latestFindingRequest = result.current.loadFindingLibrary() + }) + await act(async () => { + latestFindingLoad.resolve([{ finding: findingFixture({ id: 'finding-latest' }), sessionTitle: 'Latest' }]) + await latestFindingRequest + olderFindingLoad.resolve([{ finding: findingFixture({ id: 'finding-stale' }), sessionTitle: 'Stale' }]) + await olderFindingRequest + }) + expect(result.current.findingLibrary.map((item) => item.finding.id)).toEqual(['finding-latest']) + }) +}) diff --git a/frontend/src/app/useAppController.sessionIntegrity.test.ts b/frontend/src/app/useAppController.sessionIntegrity.test.ts new file mode 100644 index 0000000..93fe17d --- /dev/null +++ b/frontend/src/app/useAppController.sessionIntegrity.test.ts @@ -0,0 +1,127 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { generationStatusFixture, sessionFixture } from '../test/fixtures' +import { richEditorDocumentFromPlainText } from '../editor/editorDocument' +import { + cleanupControllerTest, + getTauriMock, + getTauriWindowMock, + setupControllerTest, + useAppController, +} from './useAppController.testHarness' + +const tauriMock = getTauriMock() +const tauriWindowMock = getTauriWindowMock() + +describe('useAppController Session integrity', () => { + beforeEach(setupControllerTest) + afterEach(cleanupControllerTest) + + it('keeps a blank title pending across autosave, Session navigation, and beforeunload', async () => { + vi.useFakeTimers() + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + + act(() => result.current.setSessionTitle(' ')) + expect(result.current.sessionTitleValidationError).toBe('Session title is required.') + expect(result.current.sessionSaveState).toBe('invalid') + await act(async () => { await vi.advanceTimersByTimeAsync(800) }) + expect(tauriMock.updateSession).not.toHaveBeenCalled() + + const unload = new Event('beforeunload', { cancelable: true }) + await act(async () => { + window.dispatchEvent(unload) + await Promise.resolve() + }) + expect(unload.defaultPrevented).toBe(true) + + const otherSession = sessionFixture({ id: 'session-2', title: 'Other Session' }) + await act(async () => { await result.current.openSession(otherSession) }) + expect(tauriMock.openSessionNoteState).not.toHaveBeenCalledWith('session-2') + expect(result.current.activeSession?.id).toBe('session-1') + expect(result.current.sessionTitle).toBe(' ') + }) + + it('stops startup discovery after its bounded retry budget without hydrating a Note', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockRejectedValue(new Error('Discovery unavailable')) + const { result } = renderHook(() => useAppController()) + + await act(async () => { await vi.advanceTimersByTimeAsync(2000) }) + expect(tauriMock.listActiveAiActionJobs).toHaveBeenCalledTimes(3) + expect(tauriMock.openSessionNoteState).not.toHaveBeenCalled() + expect(result.current.activeSession).toBeNull() + expect(result.current.error).toContain('Discovery unavailable') + + await act(async () => { await vi.advanceTimersByTimeAsync(10_000) }) + expect(tauriMock.listActiveAiActionJobs).toHaveBeenCalledTimes(3) + + await act(async () => { await result.current.handleLoadSessionLibrary() }) + expect(result.current.sessions).toHaveLength(1) + await act(async () => { await result.current.openSession(result.current.sessions[0]) }) + expect(tauriMock.openSessionNoteState).not.toHaveBeenCalled() + expect(result.current.activeSession).toBeNull() + expect(result.current.error).toContain('Restart QA Scribe') + + await act(async () => { await result.current.handleNewSession() }) + expect(tauriMock.createSession).not.toHaveBeenCalled() + expect(result.current.noteEntry).toBeNull() + }) + + it('stops repeated recovered-job status errors and leaves dirty Note saves protected', async () => { + vi.useFakeTimers() + tauriMock.listActiveAiActionJobs.mockResolvedValue([ + generationStatusFixture({ jobId: 'job-summary', action: 'summary', state: 'running' }), + ]) + tauriMock.getAiActionJobStatus.mockRejectedValue(new Error('Status unavailable')) + const { result } = renderHook(() => useAppController()) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Dirty while status is unknown'))) + await act(async () => { await vi.advanceTimersByTimeAsync(3000) }) + expect(tauriMock.getAiActionJobStatus).toHaveBeenCalledTimes(3) + expect(await result.current.saveNoteNow()).toBe(false) + expect(tauriMock.updateEntry).not.toHaveBeenCalled() + + await act(async () => { await vi.advanceTimersByTimeAsync(10_000) }) + expect(tauriMock.getAiActionJobStatus).toHaveBeenCalledTimes(3) + }) + + it('prevents native close for an invalid title and restores it on explicit discard', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + await waitFor(() => expect(tauriWindowMock.closeRequestedHandler()).toBeTruthy()) + + act(() => result.current.setSessionTitle('')) + const closeEvent = { preventDefault: vi.fn() } + await act(async () => { await tauriWindowMock.closeRequestedHandler()?.(closeEvent) }) + expect(closeEvent.preventDefault).toHaveBeenCalled() + expect(tauriWindowMock.currentWindow.destroy).not.toHaveBeenCalled() + expect(result.current.error).toBe('Session title is required.') + + act(() => result.current.setActiveView('settings')) + expect(result.current.pendingNavigationView).toBe('settings') + expect(result.current.activeView).toBe('sessions') + await act(async () => { await result.current.discardPendingNavigationChanges() }) + expect(result.current.sessionTitle).toBe('Checkout session') + expect(result.current.sessionSaveState).toBe('saved') + expect(result.current.activeView).toBe('settings') + }) + + it('keeps a failed title save unsaved until the edit is discarded', async () => { + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + tauriMock.updateSession.mockRejectedValueOnce(new Error('offline')) + + act(() => result.current.setSessionTitle('Renamed Session')) + let saved = true + await act(async () => { saved = await result.current.saveNoteNow() }) + expect(saved).toBe(false) + expect(result.current.sessionSaveState).toBe('unsaved') + expect(result.current.error).toContain('offline') + + await act(async () => { await result.current.discardPendingSessionEdits() }) + expect(result.current.sessionTitle).toBe('Checkout session') + expect(result.current.sessionSaveState).toBe('saved') + }) +}) diff --git a/frontend/src/app/useAppController.testHarness.ts b/frontend/src/app/useAppController.testHarness.ts index a4f6b99..e260a5f 100644 --- a/frontend/src/app/useAppController.testHarness.ts +++ b/frontend/src/app/useAppController.testHarness.ts @@ -5,7 +5,7 @@ import { draftFixture, entryFixture, findingFixture, providerStatusFixture, sess const tauriMock = vi.hoisted(() => ({ cancelAiActionJob: vi.fn(), getAiActionJobStatus: vi.fn(), listActiveAiActionJobs: vi.fn(), createDraft: vi.fn(), createEntry: vi.fn(), createFinding: vi.fn(), createSession: vi.fn(), - deleteDraft: vi.fn(), deleteFinding: vi.fn(), deleteSession: vi.fn(), getProviderStatus: vi.fn(), + deleteAttachment: vi.fn(), deleteDraft: vi.fn(), deleteFinding: vi.fn(), deleteSession: vi.fn(), getProviderStatus: vi.fn(), getSettings: vi.fn(), importClipboardScreenshot: vi.fn(), listDraftLibrary: vi.fn(), listDrafts: vi.fn(), listEntries: vi.fn(), listFindingLibrary: vi.fn(), listFindings: vi.fn(), listRecentSessions: vi.fn(), listSessions: vi.fn(), openSessionNoteState: vi.fn(), reopenSession: vi.fn(), refreshProviderStatus: vi.fn(), startAiActionJob: vi.fn(), updateDraft: vi.fn(), @@ -63,6 +63,7 @@ export function setupControllerTest() { tauriMock.getSettings.mockResolvedValue(settingsFixture()) tauriMock.importClipboardScreenshot.mockResolvedValue({ id: 'attachment-1', filename: 'inline-image.png' }) + tauriMock.deleteAttachment.mockResolvedValue(true) tauriMock.getProviderStatus.mockResolvedValue(providerStatusFixture()) tauriMock.refreshProviderStatus.mockResolvedValue(providerStatusFixture()) tauriMock.listRecentSessions.mockResolvedValue([sessionFixture()]) diff --git a/frontend/src/app/useAppController.ts b/frontend/src/app/useAppController.ts index fac6e1c..0090b61 100644 --- a/frontend/src/app/useAppController.ts +++ b/frontend/src/app/useAppController.ts @@ -1,21 +1,14 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { reopenSession, type GenerateAiActionKind } from '../tauri' -import { richEditorDocumentToPlainText, serializeRichEditorDocument } from '../editor/editorDocument' -import { managedAttachmentReferencesForClipboard } from '../editor/clipboardExport' -import { countWords, formatError } from '../ui/format' -import type { PendingAiActions, MainView } from '../ui/types' +import { useEffect, useRef, useState } from 'react' +import { serializeRichEditorDocument } from '../editor/editorDocument' +import type { MainView } from '../ui/types' import { useSettingsController } from '../hooks/useSettingsController' import { deleteConfirmationCopy } from '../workflows/deleteConfirmation' -import { useAttachmentActions } from './attachmentActions' +import { useAttachmentActions, type AttachmentUploadOwner } from './attachmentActions' import { useCopyActions } from './copyActions' -import { generationIsActive, useGenerationActions } from './generationActions' +import { useGenerationActions } from './generationActions' import { useRecordActions } from './recordActions' import { useSessionActions } from './sessionActions' -import type { - AiSelection, - RecordWorkspace, - WorkflowNavigation, -} from './types' +import type { AiSelection, RecordWorkspace, WorkflowNavigation } from './types' import { useAppStartup } from './useAppStartup' import { usePendingChangeProtection } from './usePendingChangeProtection' import { useRecordHydration } from './useRecordHydration' @@ -24,9 +17,14 @@ import { useWorkflowFeedback } from './useWorkflowFeedback' import { useGenerationWorkspace } from './useGenerationWorkspace' import { useDeletionWorkspace } from './useDeletionWorkspace' import { useOutputLibraries } from './useOutputLibraries' -import { navigationHash, parseNavigationRoute, type AppNavigationRoute } from './navigationRoute' +import { navigationHash, parseNavigationRoute } from './navigationRoute' import { createOpenGenerationPreflight } from './generationPreflightAction' import { useSettingsDiscovery } from './useSettingsDiscovery' +import { + useAppControllerGenerationState, + useAppControllerPresentationState, +} from './useAppController.derivedState' +import { useAppControllerNavigation } from './useAppController.navigation' export { mergeRecordLists } from './useRecordHydration' @@ -54,13 +52,26 @@ export function useAppController() { pendingGenerationAction, latestNoteGenerationUndo, setPendingGenerationAction, - setLatestNoteGenerationUndo, + setLatestNoteGenerationUndo: setLatestNoteGenerationUndoState, workspace: generationWorkspace, } = useGenerationWorkspace() + const latestNoteGenerationUndoRef = useRef(latestNoteGenerationUndo) + function setLatestNoteGenerationUndo(value: Parameters[0]) { + const next = typeof value === 'function' ? value(latestNoteGenerationUndoRef.current) : value + latestNoteGenerationUndoRef.current = next + setLatestNoteGenerationUndoState(next) + } + const coordinatedGenerationWorkspace = { + ...generationWorkspace, + latestNoteGenerationUndo, + setLatestNoteGenerationUndo, + } const { deleteConfirmation, setDeleteConfirmation, workspace: deletion } = useDeletionWorkspace() const [searchQuery, setSearchQuery] = useState('') const [activeView, setActiveView] = useState('sessions') const [pendingNavigationView, setPendingNavigationView] = useState(null) + const pendingNavigationContinuationRef = useRef<(() => void | Promise) | null>(null) + const pendingNavigationEpochRef = useRef(0) const [pendingSettingsSection, setPendingSettingsSection] = useState(null) const [settingsSection, setSettingsSection] = useState(null) const [focusedRecordId, setFocusedRecordId] = useState(null) @@ -69,6 +80,15 @@ export function useAppController() { const settingsReturnViewRef = useRef('sessions') const outputLibraries = useOutputLibraries(activeView) const saveDirtyRecordsNowRef = useRef<() => Promise>(() => Promise.resolve(true)) + const retryPendingRecordCompensationsRef = useRef<(sessionId: string) => Promise>(() => Promise.resolve(true)) + const summaryRecovery = { + recoveredJobsRef: useRef(new Map()), + unresolvedSummaryJobsRef: useRef(new Map()), + completedSummaryEntriesRef: useRef(new Map>()), + openingSessionIdRef: useRef(null), + discoveryPendingRef: useRef(true), + blockedSaveSessionIdsRef: useRef(new Set()), + } const { activeProvider, discoverProviderDefaults, @@ -109,68 +129,50 @@ export function useAppController() { setFindings, setTestwareDraftCount, setFindingCount, - invalidateRecordLoads, + invalidateDraftLoads, + invalidateFindingLoads, resetRecordHydration, + suspendRecordLoads, + restoreRecordLoads, loadDraftsForSession, loadFindingsForSession, } = useRecordHydration({ activeSessionId: activeSession?.id ?? null, activeView }) - - // Memoized so its reference is stable across renders: `draftScreenshotCounts` - // depends on it, and an inline `drafts.filter(...)` would produce a fresh array - // every keystroke and re-run that DOMParser-backed memo needlessly. - const testwareDrafts = useMemo(() => drafts.filter((draft) => draft.kind === 'testware'), [drafts]) - const noteScreenshotCount = useMemo( - () => managedAttachmentReferencesForClipboard({ title: sessionTitle, bodyHtml: noteBodyHtml }).length, - [noteBodyHtml, sessionTitle], - ) - const draftScreenshotCounts = useMemo( - () => - Object.fromEntries( - testwareDrafts.map((draft) => [ - draft.id, - managedAttachmentReferencesForClipboard({ title: draft.title, bodyHtml: draft.body }).length, - ]), - ), - [testwareDrafts], - ) - const findingScreenshotCounts = useMemo( - () => - Object.fromEntries( - findings.map((finding) => [ - finding.id, - managedAttachmentReferencesForClipboard({ title: finding.title, bodyHtml: finding.body }).length, - ]), - ), - [findings], - ) - const filteredSessions = useMemo(() => { - const query = searchQuery.trim().toLocaleLowerCase() - if (!query) return sessions - return sessions.filter((session) => session.title.toLocaleLowerCase().includes(query)) - }, [sessions, searchQuery]) - // Memoized: `richEditorDocumentToPlainText` walks/serializes the document, so - // recomputing it on every unrelated render (e.g. a sibling state change) was - // wasted work on the keystroke path. - const noteWordCount = useMemo(() => countWords(richEditorDocumentToPlainText(noteBody)), [noteBody]) + const { + draftScreenshotCounts, filteredSessions, findingScreenshotCounts, + noteScreenshotCount, noteWordCount, testwareDrafts, + } = useAppControllerPresentationState({ + drafts, findings, noteBody, noteBodyHtml, searchQuery, sessionTitle, sessions, + }) const openGenerationPreflight = createOpenGenerationPreflight( activeProvider, refreshProviderStatus, setBusyAction, setPendingGenerationAction, ) const noteIsReady = Boolean(activeSession && noteEntry) + const sessionTitleValidationError = activeSession && !sessionTitle.trim() + ? 'Session title is required.' + : null + const pendingRecoveredSummaryDecision = Boolean( + latestNoteGenerationUndo?.pendingRecoveryDecision + && latestNoteGenerationUndo.entryId === noteEntry?.id, + ) + /* eslint-disable react-hooks/refs -- these write-through refs are the authoritative retry baselines; every mutation is paired with state that schedules a render. */ + const sessionHasPendingChanges = Boolean( + pendingRecoveredSummaryDecision + || (activeSession && sessionTitle !== savedTitleRef.current) + || (noteEntry && serializeRichEditorDocument(noteBody) !== savedBodyRef.current), + ) + /* eslint-enable react-hooks/refs */ + const sessionSaveState = sessionTitleValidationError + ? 'invalid' as const + : busyAction === 'save-title' || busyAction === 'save-body' + ? 'saving' as const + : sessionHasPendingChanges + ? 'unsaved' as const + : 'saved' as const const isBusy = busyAction !== null const deleteCopy = deleteConfirmation ? deleteConfirmationCopy(deleteConfirmation) : null - const activeSessionJobs = useMemo( - () => Object.values(generationJobs).filter((job) => activeSession && job.sessionId === activeSession.id && generationIsActive(job)), - [generationJobs, activeSession], - ) - const pendingAiActions = useMemo(() => { - const pending: PendingAiActions = {} - // `job.action` is the backend's `GenerationJobStatus.action: String`, which - // is always a `GenerateAiActionKind` value. - for (const job of activeSessionJobs) pending[job.action as GenerateAiActionKind] = true - return pending - }, [activeSessionJobs]) - const activeTestwareJob = activeSessionJobs.find((job) => job.action === 'testware') ?? null - const activeFindingJob = activeSessionJobs.find((job) => job.action === 'finding') ?? null + const { activeFindingJob, activeTestwareJob, pendingAiActions } = useAppControllerGenerationState({ + activeSession, generationJobs, + }) const recordWorkspace: RecordWorkspace = { drafts, @@ -194,28 +196,48 @@ export function useAppController() { const navigation: WorkflowNavigation = { setActiveView, } - const attachmentActions = useAttachmentActions({ session: sessionWorkspace, feedback }) + const registerImportedAttachmentRef = useRef<(owner: AttachmentUploadOwner, attachmentId: string) => void>(() => {}) + const attachmentActions = useAttachmentActions({ + session: sessionWorkspace, + feedback, + registerImportedAttachment: (owner, attachmentId) => { + registerImportedAttachmentRef.current(owner, attachmentId) + }, + }) const sessionActions = useSessionActions({ session: sessionWorkspace, records: recordWorkspace, - generation: generationWorkspace, + generation: coordinatedGenerationWorkspace, latestNoteGenerationUndoRef, summaryRecovery, feedback, navigation, deletion, materializeInlineImages: attachmentActions.materializeInlineImages, - invalidateRecordLoads, + cleanupMaterializedAttachments: attachmentActions.cleanupMaterializedAttachments, + suspendRecordLoads, + restoreRecordLoads, resetRecordHydration, saveDirtyRecordsNow: () => saveDirtyRecordsNowRef.current(), + retryPendingRecordCompensations: (sessionId) => retryPendingRecordCompensationsRef.current(sessionId), }) - const generationActions = useGenerationActions({ - session: sessionWorkspace, - records: recordWorkspace, - generation: generationWorkspace, - selection, - feedback, - navigation, - saveNoteNow: sessionActions.saveNoteNow, - }) + function setEditedSessionTitle(value: Parameters[0]) { + const next = typeof value === 'function' ? value(sessionWorkspace.sessionTitleRef.current) : value + if (next === sessionWorkspace.sessionTitleRef.current) return + sessionActions.registerTitleEditIntent() + setSessionTitle(next) + } + function setEditedNoteBody(value: Parameters[0]) { + const next = typeof value === 'function' ? value(sessionWorkspace.noteBodyRef.current) : value + sessionActions.registerNoteEditIntent(next) + const recoveryDecision = latestNoteGenerationUndoRef.current + if (recoveryDecision?.pendingRecoveryDecision) { + setLatestNoteGenerationUndo(recoveryDecision.pendingRecoveryChoice === 'authored' + ? { ...recoveryDecision, before: next } + : { ...recoveryDecision, generated: next, pendingRecoveryChoice: 'generated' }) + } else { + setLatestNoteGenerationUndo(null) + } + setNoteBody(next) + } const recordActions = useRecordActions({ session: sessionWorkspace, records: recordWorkspace, @@ -223,104 +245,51 @@ export function useAppController() { navigation, deletion, saveNoteNow: sessionActions.saveNoteNow, + registerRecordEditIntent: sessionActions.registerRecordEditIntent, handleDeleteSession: sessionActions.handleDeleteSession, materializeInlineImages: attachmentActions.materializeInlineImages, + cleanupMaterializedAttachments: attachmentActions.cleanupMaterializedAttachments, + invalidateDraftLoads, + invalidateFindingLoads, loaders: { loadDraftsForSession, loadFindingsForSession, }, }) + const generationActions = useGenerationActions({ + session: sessionWorkspace, + records: recordWorkspace, + generation: coordinatedGenerationWorkspace, latestNoteGenerationUndoRef, summaryRecovery, + selection, + feedback, + navigation, + saveNoteNow: sessionActions.saveNoteNow, + saveNoteBody: sessionActions.saveBody, + adoptCanonicalNoteBody: sessionActions.adoptCanonicalNoteBody, + canonicalizeGeneratedDraft: recordActions.canonicalizeGeneratedDraft, + canonicalizeGeneratedFinding: recordActions.canonicalizeGeneratedFinding, + }) const copyActions = useCopyActions({ source: sessionWorkspace, feedback, copy: copyFeedback }) - function requestActiveView(view: MainView) { - if (view === activeView) return - if (settingsDirty || dirtyDraftIdsRef.current.size > 0 || dirtyFindingIdsRef.current.size > 0) { - setPendingNavigationView(view) - return - } - setActiveView(view) - } - - function openSettingsSection(sectionId?: string) { - if (activeView !== 'settings') settingsReturnViewRef.current = activeView - const nextSection = sectionId ?? null - setSettingsSection(nextSection) - setPendingSettingsSection(nextSection) - requestActiveView('settings') - } - - function closeSettings() { requestActiveView(settingsReturnViewRef.current) } - function openSessionInCurrentView(session: (typeof sessions)[number]) { - const destination = activeView === 'testware' || activeView === 'findings' ? activeView : 'sessions' - return sessionActions.openSession(session, true, () => setActiveView(destination)) - } - - async function openLibraryRecord(sessionId: string, view: 'testware' | 'findings', recordId: string) { - const session = sessions.find((candidate) => candidate.id === sessionId) ?? await reopenSession(sessionId) - await sessionActions.openSession(session, false, () => { - setFocusedRecordId(recordId) - setActiveView(view) - }) - } - - async function applyNavigationRoute(route: AppNavigationRoute) { - if (route.kind === 'settings') { - if (activeView !== 'settings') settingsReturnViewRef.current = activeView - setSettingsSection(route.sectionId) - setPendingSettingsSection(route.sectionId) - requestActiveView('settings') - return - } - if (route.kind === 'library') { - requestActiveView(route.view) - return - } - setFocusedRecordId(route.recordId) - if (!route.sessionId || activeSession?.id === route.sessionId) { - requestActiveView(route.view) - return - } - try { - const session = sessions.find((candidate) => candidate.id === route.sessionId) ?? await reopenSession(route.sessionId) - await sessionActions.openSession(session, false, () => setActiveView(route.view)) - } catch (cause) { - setError(`Could not open the linked workspace. ${formatError(cause)}`) - } - } - - async function savePendingNavigationChanges() { - const recordsSaved = await recordActions.saveDirtyRecordsNow() - const settingsSaved = !settingsDirty || await saveSettingsDraft() - if (!recordsSaved || !settingsSaved || !pendingNavigationView) return - setActiveView(pendingNavigationView) - setPendingNavigationView(null) - } - - function discardPendingNavigationChanges() { - if (!pendingNavigationView) return - recordActions.discardAllDirtyRecords() - if (settingsDirty) discardSettingsDraft() - setActiveView(pendingNavigationView) - setPendingNavigationView(null) - } - - function cancelPendingNavigation() { - setPendingNavigationView(null) - window.history.replaceState(null, '', navigationHash({ - activeView, - sessionId: activeSession?.id ?? null, - focusedRecordId, - settingsSectionId: settingsSection, - })) - } - - async function saveAllPendingChanges(): Promise { - const sessionSaved = await sessionActions.savePendingSessionEdits() - if (!sessionSaved) return false - return !settingsDirty || saveSettingsDraft() - } + const { + applyNavigationRoute, cancelPendingNavigation, closeSettings, discardPendingNavigationChanges, + hasPendingCompensations, openLibraryRecord, openSessionInCurrentView, openSettingsSection, + requestActiveView, requestNewSession, requestOpenSession, saveAllPendingChanges, + savePendingNavigationChanges, + } = useAppControllerNavigation({ + activeSession, activeView, attachmentActions, dirtyDraftIdsRef, dirtyFindingIdsRef, + discardSettingsDraft, focusedRecordId, pendingNavigationContinuationRef, pendingNavigationEpochRef, + pendingNavigationView, pendingRecoveredSummaryDecision, recordActions, savedTitleRef, saveSettingsDraft, + sessionActions, sessions, sessionTitle, sessionWorkspace, settingsDirty, settingsReturnViewRef, settingsSection, + setActiveView, setError, setFocusedRecordId, setPendingNavigationView, setPendingSettingsSection, setSettingsSection, + }) useEffect(() => { + registerImportedAttachmentRef.current = (owner, attachmentId) => { + if (owner.kind === 'note') sessionActions.registerImportedNoteAttachment(owner.id, attachmentId) + else recordActions.registerImportedRecordAttachment({ kind: owner.kind, id: owner.id }, attachmentId) + } saveDirtyRecordsNowRef.current = recordActions.saveDirtyRecordsNow + retryPendingRecordCompensationsRef.current = recordActions.retryPendingRecordCompensations }) useEffect(() => { @@ -343,15 +312,17 @@ export function useAppController() { noteBody, savedTitleRef, savedBodyRef, + pendingRecoveredSummaryDecision, dirtyDraftIdsRef, dirtyFindingIdsRef, settingsDirty, + hasPendingCompensations, savePendingChanges: saveAllPendingChanges, }) const { bootedRef, handleLoadSessionLibrary, handleRefreshProviderStatus, handleSaveSettings } = useAppStartup({ loadSettings, - openSession: sessionActions.openSession, + openSession: sessionActions.openSession, captureActiveJobs: generationActions.captureActiveJobs, reconcileActiveJobs: generationActions.reconcileActiveJobs, loadProviderStatus, refreshProviderStatus, @@ -395,11 +366,11 @@ export function useAppController() { useEffect(() => { if (!activeSession || !bootedRef.current) return const trimmedTitle = sessionTitle.trim() - if (!trimmedTitle || trimmedTitle === savedTitleRef.current) return + if (!trimmedTitle || sessionTitle === savedTitleRef.current) return const timeout = window.setTimeout(() => { if (suppressAmbientNoteSaveRef.current) return - void sessionActions.saveTitle(trimmedTitle) + void sessionActions.saveTitle(sessionTitle) }, 700) return () => window.clearTimeout(timeout) }, [activeSession, sessionTitle]) // eslint-disable-line react-hooks/exhaustive-deps -- debounce is keyed to Session identity and title @@ -408,13 +379,16 @@ export function useAppController() { if (!noteEntry || !bootedRef.current) return const nextBody = serializeRichEditorDocument(noteBody) if (nextBody === savedBodyRef.current) return + // Recovery conflicts require an explicit save/discard choice. Ambient + // autosave must not silently choose the authored side of that decision. + if (pendingRecoveredSummaryDecision) return const timeout = window.setTimeout(() => { if (suppressAmbientNoteSaveRef.current) return void sessionActions.saveNoteNow() }, 850) return () => window.clearTimeout(timeout) - }, [noteEntry, noteBody]) // eslint-disable-line react-hooks/exhaustive-deps -- debounce is keyed to note identity and body + }, [noteEntry, noteBody, pendingRecoveredSummaryDecision]) // eslint-disable-line react-hooks/exhaustive-deps -- debounce is keyed to note identity, body, and recovery decision return { ...attachmentActions, @@ -454,16 +428,21 @@ export function useAppController() { noteIsReady, noteScreenshotCount, sessionTitle, + sessionTitleValidationError, + sessionSaveState, noteWordCount, closeSettings, openSettingsSection, openSessionInCurrentView, + openSession: requestOpenSession, + handleNewSession: requestNewSession, openLibraryRecord, openGenerationPreflight, notice, pendingAiActions, pendingGenerationAction, pendingNavigationView, + pendingRecoveredSummaryDecision, providerDiscoveryState, providerStatus, searchQuery, @@ -478,8 +457,8 @@ export function useAppController() { setDeleteConfirmation, setError, setLatestNoteGenerationUndo, - setNoteBody, - setSessionTitle, + setNoteBody: setEditedNoteBody, + setSessionTitle: setEditedSessionTitle, setPendingGenerationAction, setSearchQuery, setTheme, diff --git a/frontend/src/app/useAppController.workflows.deletion.test.ts b/frontend/src/app/useAppController.workflows.deletion.test.ts new file mode 100644 index 0000000..302a12d --- /dev/null +++ b/frontend/src/app/useAppController.workflows.deletion.test.ts @@ -0,0 +1,438 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Editor } from '@tiptap/react' +import { draftFixture, entryFixture, findingFixture, generationStatusFixture, sessionFixture } from '../test/fixtures' +import { richEditorDocumentFromPlainText } from '../editor/editorDocument' +import { registerRichEditor } from '../editor/richEditorRegistry' +import { cleanupControllerTest, deferred, getTauriMock, sessionNoteStateFixture, setupControllerTest, useAppController } from './useAppController.testHarness' + +const tauriMock = getTauriMock() + +describe('useAppController workflows and record hydration', () => { + beforeEach(setupControllerTest) + afterEach(cleanupControllerTest) + + it('finalizes Draft deletion before failed attachment cleanup settles', async () => { + const original = draftFixture({ id: 'draft-delete-cleanup', body: '

Saved draft.

' }) + const cleanup = deferred() + const staleLoad = deferred[]>() + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ testwareDraftCount: 1 })) + tauriMock.listDrafts + .mockResolvedValueOnce([original]) + .mockReturnValueOnce(staleLoad.promise) + .mockRejectedValueOnce(new Error('Draft refresh unavailable')) + tauriMock.importClipboardScreenshot.mockResolvedValueOnce({ id: 'attachment-draft-delete', filename: 'draft.png' }) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('testware')) + await waitFor(() => expect(result.current.testwareDrafts).toEqual([original])) + + const editorId = 'draft-delete-cleanup-editor' + const insertImage = vi.fn((attachmentId: string) => { + result.current.updateLocalDraft(original.id, { + body: `

Edited draft.

Evidence`, + bodyJson: null, + bodyFormat: 'html', + }) + return true + }) + const unregister = registerRichEditor(editorId, { + editor: {} as Editor, + insertImage, + readOnly: false, + }) + + await act(async () => { + await result.current.uploadEditorImage({ + editorId, + file: new File(['image'], 'draft.png', { type: 'image/png' }), + insertImage, + }, { kind: 'draft', id: original.id }) + }) + let staleLoadPromise!: Promise[]> + act(() => { staleLoadPromise = result.current.loadDraftsForSession('session-1', { force: true }) }) + await waitFor(() => expect(tauriMock.listDrafts).toHaveBeenCalledTimes(2)) + tauriMock.deleteAttachment.mockReturnValueOnce(cleanup.promise) + act(() => result.current.requestDeleteDraft(result.current.testwareDrafts[0])) + let deletePromise!: Promise + act(() => { deletePromise = result.current.confirmDelete() }) + await waitFor(() => expect(tauriMock.deleteAttachment).toHaveBeenCalledWith('attachment-draft-delete')) + + expect(result.current.testwareDraftCount).toBe(0) + await act(async () => { + staleLoad.resolve([original]) + await staleLoadPromise + }) + expect(result.current.testwareDrafts).toEqual([]) + expect(result.current.testwareDraftCount).toBe(0) + await act(async () => { expect(await result.current.saveDirtyRecordsNow()).toBe(true) }) + expect(tauriMock.updateDraft).not.toHaveBeenCalled() + await act(async () => { + cleanup.resolve(false) + await deletePromise + }) + + expect(result.current.testwareDrafts).toEqual([]) + expect(result.current.notice).toContain('image cleanup will retry') + expect(result.current.error).toContain('Draft refresh unavailable') + unregister() + }) + + it('finalizes Finding deletion before failed attachment cleanup settles', async () => { + const original = findingFixture({ id: 'finding-delete-cleanup', body: '

Saved finding.

' }) + const cleanup = deferred() + const staleLoad = deferred[]>() + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ findingCount: 1 })) + tauriMock.listFindings + .mockResolvedValueOnce([original]) + .mockReturnValueOnce(staleLoad.promise) + .mockRejectedValueOnce(new Error('Finding refresh unavailable')) + tauriMock.importClipboardScreenshot.mockResolvedValueOnce({ id: 'attachment-finding-delete', filename: 'finding.png' }) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('findings')) + await waitFor(() => expect(result.current.findings).toEqual([original])) + + const editorId = 'finding-delete-cleanup-editor' + const insertImage = vi.fn((attachmentId: string) => { + result.current.updateLocalFinding(original.id, { + body: `

Edited finding.

Evidence`, + bodyJson: null, + bodyFormat: 'html', + }) + return true + }) + const unregister = registerRichEditor(editorId, { + editor: {} as Editor, + insertImage, + readOnly: false, + }) + + await act(async () => { + await result.current.uploadEditorImage({ + editorId, + file: new File(['image'], 'finding.png', { type: 'image/png' }), + insertImage, + }, { kind: 'finding', id: original.id }) + }) + let staleLoadPromise!: Promise[]> + act(() => { staleLoadPromise = result.current.loadFindingsForSession('session-1', { force: true }) }) + await waitFor(() => expect(tauriMock.listFindings).toHaveBeenCalledTimes(2)) + tauriMock.deleteAttachment.mockReturnValueOnce(cleanup.promise) + act(() => result.current.requestDeleteFinding(result.current.findings[0])) + let deletePromise!: Promise + act(() => { deletePromise = result.current.confirmDelete() }) + await waitFor(() => expect(tauriMock.deleteAttachment).toHaveBeenCalledWith('attachment-finding-delete')) + + expect(result.current.findingCount).toBe(0) + await act(async () => { + staleLoad.resolve([original]) + await staleLoadPromise + }) + expect(result.current.findings).toEqual([]) + expect(result.current.findingCount).toBe(0) + await act(async () => { expect(await result.current.saveDirtyRecordsNow()).toBe(true) }) + expect(tauriMock.updateFinding).not.toHaveBeenCalled() + await act(async () => { + cleanup.resolve(false) + await deletePromise + }) + + expect(result.current.findings).toEqual([]) + expect(result.current.notice).toContain('image cleanup will retry') + expect(result.current.error).toContain('Finding refresh unavailable') + unregister() + }) + + it('does not resurrect a deleted Draft when generated canonicalization settles late', async () => { + const canonicalization = deferred>() + const generated = draftFixture({ id: 'draft-delete-canonicalization', body: '

Generated Draft.

' }) + tauriMock.updateDraft.mockReturnValueOnce(canonicalization.promise) + tauriMock.listDrafts.mockResolvedValueOnce([]) + tauriMock.startAiActionJob.mockImplementationOnce(async (_request: unknown, onEvent: (event: unknown) => void) => { + onEvent(completedRecordGenerationEvent({ draft: generated })) + return { jobId: 'job-draft-delete', status: generationStatusFixture({ jobId: 'job-draft-delete', action: 'testware', state: 'completed' }) } + }) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + await act(async () => { await result.current.handleAiAction('testware') }) + await waitFor(() => expect(result.current.testwareDrafts.map((draft) => draft.id)).toEqual([generated.id])) + + act(() => result.current.requestDeleteDraft(result.current.testwareDrafts[0])) + await act(async () => { await result.current.confirmDelete() }) + expect(result.current.testwareDrafts).toEqual([]) + + await act(async () => { + canonicalization.resolve(generated) + await canonicalization.promise + }) + + expect(result.current.testwareDrafts).toEqual([]) + expect(tauriMock.updateDraft).toHaveBeenCalledTimes(1) + }) + + it('does not resurrect a deleted Finding when generated canonicalization settles late', async () => { + const canonicalization = deferred>() + const generated = findingFixture({ id: 'finding-delete-canonicalization', body: '

Generated Finding.

' }) + tauriMock.updateFinding.mockReturnValueOnce(canonicalization.promise) + tauriMock.listFindings.mockResolvedValueOnce([]) + tauriMock.startAiActionJob.mockImplementationOnce(async (_request: unknown, onEvent: (event: unknown) => void) => { + onEvent(completedRecordGenerationEvent({ finding: generated })) + return { jobId: 'job-finding-delete', status: generationStatusFixture({ jobId: 'job-finding-delete', action: 'finding', state: 'completed' }) } + }) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + await act(async () => { await result.current.handleAiAction('finding') }) + await waitFor(() => expect(result.current.findings.map((finding) => finding.id)).toEqual([generated.id])) + + act(() => result.current.requestDeleteFinding(result.current.findings[0])) + await act(async () => { await result.current.confirmDelete() }) + expect(result.current.findings).toEqual([]) + + await act(async () => { + canonicalization.resolve(generated) + await canonicalization.promise + }) + + expect(result.current.findings).toEqual([]) + expect(tauriMock.updateFinding).toHaveBeenCalledTimes(1) + }) + + it('rolls back a failed Draft delete intent so the Record can be saved and deleted again', async () => { + const original = draftFixture({ body: '

Original Draft.

' }) + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ testwareDraftCount: 1 })) + tauriMock.listDrafts.mockResolvedValueOnce([original]).mockResolvedValueOnce([]) + tauriMock.deleteDraft.mockRejectedValueOnce(new Error('Draft delete failed')).mockResolvedValueOnce(undefined) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('testware')) + await waitFor(() => expect(result.current.testwareDrafts).toEqual([original])) + + act(() => result.current.requestDeleteDraft(original)) + await act(async () => { await result.current.confirmDelete() }) + expect(result.current.testwareDrafts).toEqual([original]) + expect(result.current.testwareDraftCount).toBe(1) + + act(() => result.current.updateLocalDraft(original.id, { body: '

Saved after failed delete.

' })) + await act(async () => { expect(await result.current.handleSaveDraft(result.current.testwareDrafts[0])).toBe(true) }) + act(() => result.current.requestDeleteDraft(result.current.testwareDrafts[0])) + await act(async () => { await result.current.confirmDelete() }) + + expect(tauriMock.deleteDraft).toHaveBeenCalledTimes(2) + expect(tauriMock.updateDraft).toHaveBeenCalledWith(original.id, expect.objectContaining({ body: '

Saved after failed delete.

' })) + expect(result.current.testwareDrafts).toEqual([]) + expect(result.current.testwareDraftCount).toBe(0) + }) + + it('rolls back a failed Finding delete intent so the Record can be saved and deleted again', async () => { + const original = findingFixture({ body: '

Original Finding.

' }) + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ findingCount: 1 })) + tauriMock.listFindings.mockResolvedValueOnce([original]).mockResolvedValueOnce([]) + tauriMock.deleteFinding.mockRejectedValueOnce(new Error('Finding delete failed')).mockResolvedValueOnce(undefined) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('findings')) + await waitFor(() => expect(result.current.findings).toEqual([original])) + + act(() => result.current.requestDeleteFinding(original)) + await act(async () => { await result.current.confirmDelete() }) + expect(result.current.findings).toEqual([original]) + expect(result.current.findingCount).toBe(1) + + act(() => result.current.updateLocalFinding(original.id, { body: '

Saved after failed delete.

' })) + await act(async () => { expect(await result.current.handleSaveFinding(result.current.findings[0])).toBe(true) }) + act(() => result.current.requestDeleteFinding(result.current.findings[0])) + await act(async () => { await result.current.confirmDelete() }) + + expect(tauriMock.deleteFinding).toHaveBeenCalledTimes(2) + expect(tauriMock.updateFinding).toHaveBeenCalledWith(original.id, expect.objectContaining({ body: '

Saved after failed delete.

' })) + expect(result.current.findings).toEqual([]) + expect(result.current.findingCount).toBe(0) + }) + + it('does not resurrect a deleted Draft when an older manual save settles late', async () => { + const original = draftFixture({ body: '

Original Draft.

' }) + const lateSave = deferred>() + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ testwareDraftCount: 1 })) + tauriMock.listDrafts.mockResolvedValueOnce([original]).mockResolvedValueOnce([]) + tauriMock.updateDraft.mockReturnValueOnce(lateSave.promise) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('testware')) + await waitFor(() => expect(result.current.testwareDrafts).toEqual([original])) + act(() => result.current.updateLocalDraft(original.id, { body: '

Late Draft save.

' })) + + let savePromise!: Promise + act(() => { savePromise = result.current.handleSaveDraft(result.current.testwareDrafts[0]) }) + await waitFor(() => expect(tauriMock.updateDraft).toHaveBeenCalledTimes(1)) + act(() => result.current.requestDeleteDraft(result.current.testwareDrafts[0])) + await act(async () => { await result.current.confirmDelete() }) + + await act(async () => { + lateSave.resolve(draftFixture({ body: '

Late Draft save.

' })) + expect(await savePromise).toBe(false) + }) + + expect(result.current.testwareDrafts).toEqual([]) + expect(result.current.testwareDraftCount).toBe(0) + expect(tauriMock.updateDraft).toHaveBeenCalledTimes(1) + }) + + it('does not resurrect a deleted Finding when an older manual save settles late', async () => { + const original = findingFixture({ body: '

Original Finding.

' }) + const lateSave = deferred>() + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ findingCount: 1 })) + tauriMock.listFindings.mockResolvedValueOnce([original]).mockResolvedValueOnce([]) + tauriMock.updateFinding.mockReturnValueOnce(lateSave.promise) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('findings')) + await waitFor(() => expect(result.current.findings).toEqual([original])) + act(() => result.current.updateLocalFinding(original.id, { body: '

Late Finding save.

' })) + + let savePromise!: Promise + act(() => { savePromise = result.current.handleSaveFinding(result.current.findings[0]) }) + await waitFor(() => expect(tauriMock.updateFinding).toHaveBeenCalledTimes(1)) + act(() => result.current.requestDeleteFinding(result.current.findings[0])) + await act(async () => { await result.current.confirmDelete() }) + + await act(async () => { + lateSave.resolve(findingFixture({ body: '

Late Finding save.

' })) + expect(await savePromise).toBe(false) + }) + + expect(result.current.findings).toEqual([]) + expect(result.current.findingCount).toBe(0) + expect(tauriMock.updateFinding).toHaveBeenCalledTimes(1) + }) + + it('keeps an unrelated Finding load live while deleting a Draft', async () => { + const pendingFindings = deferred[]>() + tauriMock.listFindings.mockReturnValueOnce(pendingFindings.promise) + tauriMock.listDrafts.mockResolvedValueOnce([]) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + let loadPromise!: Promise[]> + act(() => { loadPromise = result.current.loadFindingsForSession('session-1', { force: true }) }) + await waitFor(() => expect(result.current.findingLoadState).toBe('loading')) + + act(() => result.current.requestDeleteDraft(draftFixture())) + await act(async () => { await result.current.confirmDelete() }) + await act(async () => { + pendingFindings.resolve([]) + await loadPromise + }) + + expect(result.current.findingLoadState).toBe('ready') + }) + + it('keeps an unrelated Draft load live while deleting a Finding', async () => { + const pendingDrafts = deferred[]>() + tauriMock.listDrafts.mockReturnValueOnce(pendingDrafts.promise) + tauriMock.listFindings.mockResolvedValueOnce([]) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + let loadPromise!: Promise[]> + act(() => { loadPromise = result.current.loadDraftsForSession('session-1', { force: true }) }) + await waitFor(() => expect(result.current.draftLoadState).toBe('loading')) + + act(() => result.current.requestDeleteFinding(findingFixture())) + await act(async () => { await result.current.confirmDelete() }) + await act(async () => { + pendingDrafts.resolve([]) + await loadPromise + }) + + expect(result.current.draftLoadState).toBe('ready') + }) + + it('lets the current Draft load finish when New Session creation fails', async () => { + const pendingDrafts = deferred[]>() + const restoredDraft = draftFixture({ id: 'draft-after-create-failure' }) + tauriMock.listDrafts.mockReturnValueOnce(pendingDrafts.promise).mockResolvedValueOnce([restoredDraft]) + tauriMock.createSession.mockRejectedValueOnce(new Error('Session creation failed')) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('testware')) + await waitFor(() => expect(result.current.draftLoadState).toBe('loading')) + + await act(async () => { await result.current.handleNewSession() }) + await act(async () => { + pendingDrafts.resolve([draftFixture({ id: 'stale-draft' })]) + await pendingDrafts.promise + }) + + expect(result.current.activeSession?.id).toBe('session-1') + expect(result.current.draftLoadState).toBe('ready') + expect(result.current.testwareDrafts.map((draft) => draft.id)).toEqual([restoredDraft.id]) + }) + + it('lets the current Finding load finish when Session opening is superseded', async () => { + const pendingFindings = deferred[]>() + const pendingOpen = deferred>() + const otherSession = sessionFixture({ id: 'session-2', title: 'Other Session' }) + const restoredFinding = findingFixture({ id: 'finding-after-open-superseded' }) + tauriMock.listFindings.mockReturnValueOnce(pendingFindings.promise).mockResolvedValueOnce([restoredFinding]) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('findings')) + await waitFor(() => expect(result.current.findingLoadState).toBe('loading')) + + tauriMock.openSessionNoteState.mockReturnValueOnce(pendingOpen.promise) + let openPromise!: Promise + act(() => { openPromise = result.current.openSession(otherSession) }) + await waitFor(() => expect(tauriMock.openSessionNoteState).toHaveBeenCalledWith('session-2')) + act(() => result.current.setNoteBody(richEditorDocumentFromPlainText('Superseding edit'))) + await act(async () => { + pendingOpen.resolve(sessionNoteStateFixture({ + session: otherSession, + noteEntry: entryFixture({ id: 'entry-2', sessionId: otherSession.id }), + })) + await openPromise + pendingFindings.resolve([findingFixture({ id: 'stale-finding' })]) + await pendingFindings.promise + }) + + expect(result.current.activeSession?.id).toBe('session-1') + expect(result.current.findingLoadState).toBe('ready') + expect(result.current.findings.map((finding) => finding.id)).toEqual([restoredFinding.id]) + }) +}) + +function completedRecordGenerationEvent({ + draft = null, + finding = null, +}: { + draft?: ReturnType | null + finding?: ReturnType | null +}) { + const action: 'testware' | 'finding' = draft ? 'testware' : 'finding' + return { + type: 'completed', + job_id: `job-${action}-race`, + status: generationStatusFixture({ jobId: `job-${action}-race`, action, state: 'completed' }), + result: { + generationContext: { id: `context-${action}-race`, sessionId: 'session-1', createdAt: '2026-06-24T10:00:00.000Z' }, + aiRun: { + id: `run-${action}-race`, sessionId: 'session-1', generationContextId: `context-${action}-race`, + provider: 'codex_cli', model: 'default', reasoningEffort: null, promptVersion: `${action}-v1`, + status: 'completed', errorMessage: null, createdAt: '2026-06-24T10:00:00.000Z', completedAt: '2026-06-24T10:00:00.000Z', + }, + draft, + finding, + noteEntry: null, + }, + } +} diff --git a/frontend/src/app/useAppController.workflows.record-editing.test.ts b/frontend/src/app/useAppController.workflows.record-editing.test.ts new file mode 100644 index 0000000..823e5f4 --- /dev/null +++ b/frontend/src/app/useAppController.workflows.record-editing.test.ts @@ -0,0 +1,366 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Editor } from '@tiptap/react' +import { draftFixture, entryFixture, findingFixture, generationStatusFixture, sessionFixture } from '../test/fixtures' +import { registerRichEditor } from '../editor/richEditorRegistry' +import { cleanupControllerTest, deferred, getTauriMock, sessionNoteStateFixture, setupControllerTest, useAppController } from './useAppController.testHarness' + +const tauriMock = getTauriMock() + +describe('useAppController workflows and record hydration', () => { + beforeEach(setupControllerTest) + afterEach(cleanupControllerTest) + + it('does not let generated Draft canonicalization overwrite a dirty local edit', async () => { + let resolveUpdateDraft: (draft: ReturnType) => void = () => {} + tauriMock.updateDraft.mockReturnValueOnce( + new Promise((resolve) => { + resolveUpdateDraft = resolve + }), + ) + tauriMock.startAiActionJob.mockImplementationOnce(async (_request: unknown, onEvent: (event: unknown) => void) => { + onEvent({ + type: 'completed', + job_id: 'job-1', + status: generationStatusFixture({ jobId: 'job-1', action: 'testware', state: 'completed' }), + result: { + generationContext: { id: 'context-1', sessionId: 'session-1', createdAt: '2026-06-24T10:00:00.000Z' }, + aiRun: { + id: 'run-1', + sessionId: 'session-1', + generationContextId: 'context-1', + provider: 'codex_cli', + model: 'default', + reasoningEffort: null, + promptVersion: 'testware-v4', + status: 'completed', + errorMessage: null, + createdAt: '2026-06-24T10:00:00.000Z', + completedAt: '2026-06-24T10:00:00.000Z', + }, + draft: draftFixture({ id: 'draft-generated', sessionId: 'session-1', body: '

Generated body.

' }), + finding: null, + noteEntry: null, + }, + }) + return { jobId: 'job-1', status: generationStatusFixture({ jobId: 'job-1', action: 'testware', state: 'completed' }) } + }) + + const { result } = renderHook(() => useAppController()) + + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + await act(async () => { + await result.current.handleAiAction('testware') + }) + + act(() => { + result.current.updateLocalDraft('draft-generated', { body: '

Unsaved edit after generation.

' }) + resolveUpdateDraft(draftFixture({ id: 'draft-generated', sessionId: 'session-1', body: '

Canonical server body.

' })) + }) + await act(async () => { + await Promise.resolve() + }) + + expect(result.current.testwareDrafts.find((draft) => draft.id === 'draft-generated')?.body).toBe('

Unsaved edit after generation.

') + }) + + it('materializes inline data images before saving a dirty Draft', async () => { + tauriMock.openSessionNoteState.mockResolvedValueOnce( + sessionNoteStateFixture({ + testwareDraftCount: 1, + }), + ) + tauriMock.listDrafts.mockResolvedValueOnce([draftFixture({ id: 'draft-inline', sessionId: 'session-1', body: '

Persisted draft.

' })]) + + const { result } = renderHook(() => useAppController()) + + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => { + result.current.setActiveView('testware') + }) + await waitFor(() => expect(result.current.testwareDrafts.map((draft) => draft.id)).toEqual(['draft-inline'])) + + act(() => { + result.current.updateLocalDraft('draft-inline', { body: '

Evidence

' }) + }) + await act(async () => { + await result.current.handleSaveDraft(result.current.testwareDrafts[0]) + }) + + expect(tauriMock.importClipboardScreenshot).toHaveBeenCalledWith({ + sessionId: 'session-1', + entryId: null, + filename: 'Evidence.png', + dataUrl: 'data:image/png;base64,AAAA', + }) + expect(tauriMock.updateDraft).toHaveBeenCalledWith( + 'draft-inline', + expect.objectContaining({ + body: expect.stringContaining('qa-scribe-attachment://attachment-1'), + }), + ) + expect(tauriMock.updateDraft).toHaveBeenCalledWith( + 'draft-inline', + expect.objectContaining({ + body: expect.not.stringContaining('data:image/png'), + }), + ) + }) + + it('keeps a Draft dirty when another edit happens while save is in flight', async () => { + tauriMock.openSessionNoteState.mockResolvedValueOnce( + sessionNoteStateFixture({ + testwareDraftCount: 1, + }), + ) + tauriMock.listDrafts.mockResolvedValueOnce([draftFixture({ id: 'draft-race', sessionId: 'session-1', body: '

Persisted draft.

' })]) + let resolveUpdateDraft: (draft: ReturnType) => void = () => {} + tauriMock.updateDraft.mockReturnValueOnce( + new Promise((resolve) => { + resolveUpdateDraft = resolve + }), + ) + + const { result } = renderHook(() => useAppController()) + + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => { + result.current.setActiveView('testware') + }) + await waitFor(() => expect(result.current.testwareDrafts.map((draft) => draft.id)).toEqual(['draft-race'])) + + act(() => { + result.current.updateLocalDraft('draft-race', { body: '

First unsaved edit.

' }) + }) + let savePromise!: Promise + act(() => { + savePromise = result.current.handleSaveDraft(result.current.testwareDrafts[0]) + }) + await waitFor(() => expect(tauriMock.updateDraft).toHaveBeenCalledWith('draft-race', expect.objectContaining({ body: '

First unsaved edit.

' }))) + + act(() => { + result.current.updateLocalDraft('draft-race', { body: '

Edit typed while saving.

' }) + resolveUpdateDraft(draftFixture({ id: 'draft-race', sessionId: 'session-1', body: '

First unsaved edit.

' })) + }) + await act(async () => { + await savePromise + }) + + expect(result.current.testwareDrafts.find((draft) => draft.id === 'draft-race')?.body).toBe('

Edit typed while saving.

') + + const otherSession = sessionFixture({ id: 'session-2', title: 'Other session' }) + tauriMock.openSessionNoteState.mockResolvedValueOnce( + sessionNoteStateFixture({ + session: otherSession, + noteEntry: entryFixture({ id: 'entry-2', sessionId: 'session-2' }), + }), + ) + await act(async () => { + await result.current.openSession(otherSession) + }) + + expect(tauriMock.updateDraft).toHaveBeenLastCalledWith('draft-race', expect.objectContaining({ body: '

Edit typed while saving.

' })) + expect(result.current.activeSession?.id).toBe('session-2') + }) + + it('deletes a direct Draft image import when the edit is discarded', async () => { + const original = draftFixture({ id: 'draft-direct-image', body: '

Saved draft.

' }) + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ testwareDraftCount: 1 })) + tauriMock.listDrafts.mockResolvedValueOnce([original]) + tauriMock.importClipboardScreenshot.mockResolvedValueOnce({ id: 'attachment-draft-direct', filename: 'draft.png' }) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('testware')) + await waitFor(() => expect(result.current.testwareDrafts).toEqual([original])) + + const editorId = 'draft-direct-image-editor' + const insertImage = vi.fn((attachmentId: string) => { + result.current.updateLocalDraft(original.id, { + body: `

Edited draft.

Draft evidence`, + bodyJson: null, + bodyFormat: 'html', + }) + return true + }) + const unregister = registerRichEditor(editorId, { + editor: {} as Editor, + insertImage, + readOnly: false, + }) + + await act(async () => { + await result.current.uploadEditorImage({ + editorId, + file: new File(['image'], 'draft.png', { type: 'image/png' }), + insertImage, + }, { kind: 'draft', id: original.id }) + }) + act(() => result.current.discardLocalDraft(original)) + + await waitFor(() => expect(tauriMock.deleteAttachment).toHaveBeenCalledWith('attachment-draft-direct')) + expect(result.current.testwareDrafts[0].body).toBe('

Saved draft.

') + expect(tauriMock.importClipboardScreenshot).toHaveBeenCalledWith(expect.objectContaining({ entryId: null })) + unregister() + }) + + it('preserves a newer direct Draft upload while stale-image cleanup is in flight', async () => { + const original = draftFixture({ body: '

Saved draft.

' }) + const staleCleanup = deferred() + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ testwareDraftCount: 1 })) + tauriMock.listDrafts.mockResolvedValueOnce([original]) + tauriMock.importClipboardScreenshot + .mockResolvedValueOnce({ id: 'attachment-stale-direct', filename: 'stale.png' }) + .mockResolvedValueOnce({ id: 'attachment-newer-direct', filename: 'newer.png' }) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('testware')) + await waitFor(() => expect(result.current.testwareDrafts).toEqual([original])) + + const editorId = 'draft-settlement-race-editor' + const insertImage = vi.fn((attachmentId: string) => { + result.current.updateLocalDraft(original.id, { + body: `

Draft edit.

Evidence`, + bodyJson: null, + bodyFormat: 'html', + }) + return true + }) + const unregister = registerRichEditor(editorId, { + editor: {} as Editor, + insertImage, + readOnly: false, + }) + + await act(async () => { + await result.current.uploadEditorImage({ + editorId, + file: new File(['stale'], 'stale.png', { type: 'image/png' }), + insertImage, + }, { kind: 'draft', id: original.id }) + }) + act(() => result.current.updateLocalDraft(original.id, { body: '

Save without image.

', bodyJson: null, bodyFormat: 'html' })) + tauriMock.deleteAttachment.mockReturnValueOnce(staleCleanup.promise) + + let savePromise!: Promise + act(() => { savePromise = result.current.handleSaveDraft(result.current.testwareDrafts[0]) }) + await waitFor(() => expect(tauriMock.deleteAttachment).toHaveBeenCalledWith('attachment-stale-direct')) + await act(async () => { + await result.current.uploadEditorImage({ + editorId, + file: new File(['newer'], 'newer.png', { type: 'image/png' }), + insertImage, + }, { kind: 'draft', id: original.id }) + }) + await act(async () => { + staleCleanup.resolve(true) + expect(await savePromise).toBe(false) + }) + + expect(result.current.testwareDrafts[0].body).toContain('attachment-newer-direct') + act(() => result.current.discardLocalDraft(original)) + await waitFor(() => expect(tauriMock.deleteAttachment).toHaveBeenCalledWith('attachment-newer-direct')) + unregister() + }) + + it('deletes a direct Finding image import when the edit is discarded', async () => { + const original = findingFixture({ id: 'finding-direct-image', body: '

Saved finding.

' }) + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ findingCount: 1 })) + tauriMock.listFindings.mockResolvedValueOnce([original]) + tauriMock.importClipboardScreenshot.mockResolvedValueOnce({ id: 'attachment-finding-direct', filename: 'finding.png' }) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('findings')) + await waitFor(() => expect(result.current.findings).toEqual([original])) + + const editorId = 'finding-direct-image-editor' + const insertImage = vi.fn((attachmentId: string) => { + result.current.updateLocalFinding(original.id, { + body: `

Edited finding.

Finding evidence`, + bodyJson: null, + bodyFormat: 'html', + }) + return true + }) + const unregister = registerRichEditor(editorId, { + editor: {} as Editor, + insertImage, + readOnly: false, + }) + + await act(async () => { + await result.current.uploadEditorImage({ + editorId, + file: new File(['image'], 'finding.png', { type: 'image/png' }), + insertImage, + }, { kind: 'finding', id: original.id }) + }) + act(() => result.current.discardLocalFinding(original)) + + await waitFor(() => expect(tauriMock.deleteAttachment).toHaveBeenCalledWith('attachment-finding-direct')) + expect(result.current.findings[0].body).toBe('

Saved finding.

') + unregister() + }) + + it('preserves a newer direct Finding upload while stale-image cleanup is in flight', async () => { + const original = findingFixture({ body: '

Saved finding.

' }) + const staleCleanup = deferred() + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ findingCount: 1 })) + tauriMock.listFindings.mockResolvedValueOnce([original]) + tauriMock.importClipboardScreenshot + .mockResolvedValueOnce({ id: 'attachment-stale-finding-direct', filename: 'stale.png' }) + .mockResolvedValueOnce({ id: 'attachment-newer-finding-direct', filename: 'newer.png' }) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('findings')) + await waitFor(() => expect(result.current.findings).toEqual([original])) + + const editorId = 'finding-settlement-race-editor' + const insertImage = vi.fn((attachmentId: string) => { + result.current.updateLocalFinding(original.id, { + body: `

Finding edit.

Evidence`, + bodyJson: null, + bodyFormat: 'html', + }) + return true + }) + const unregister = registerRichEditor(editorId, { + editor: {} as Editor, + insertImage, + readOnly: false, + }) + + await act(async () => { + await result.current.uploadEditorImage({ + editorId, + file: new File(['stale'], 'stale.png', { type: 'image/png' }), + insertImage, + }, { kind: 'finding', id: original.id }) + }) + act(() => result.current.updateLocalFinding(original.id, { body: '

Save without image.

', bodyJson: null, bodyFormat: 'html' })) + tauriMock.deleteAttachment.mockReturnValueOnce(staleCleanup.promise) + + let savePromise!: Promise + act(() => { savePromise = result.current.handleSaveFinding(result.current.findings[0]) }) + await waitFor(() => expect(tauriMock.deleteAttachment).toHaveBeenCalledWith('attachment-stale-finding-direct')) + await act(async () => { + await result.current.uploadEditorImage({ + editorId, + file: new File(['newer'], 'newer.png', { type: 'image/png' }), + insertImage, + }, { kind: 'finding', id: original.id }) + }) + await act(async () => { + staleCleanup.resolve(true) + expect(await savePromise).toBe(false) + }) + + expect(result.current.findings[0].body).toContain('attachment-newer-finding-direct') + act(() => result.current.discardLocalFinding(original)) + await waitFor(() => expect(tauriMock.deleteAttachment).toHaveBeenCalledWith('attachment-newer-finding-direct')) + unregister() + }) +}) diff --git a/frontend/src/app/useAppController.workflows.settlement.test.ts b/frontend/src/app/useAppController.workflows.settlement.test.ts new file mode 100644 index 0000000..8813ca0 --- /dev/null +++ b/frontend/src/app/useAppController.workflows.settlement.test.ts @@ -0,0 +1,465 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { draftFixture, entryFixture, findingFixture, generationStatusFixture } from '../test/fixtures' +import { cleanupControllerTest, deferred, getTauriMock, sessionNoteStateFixture, setupControllerTest, useAppController } from './useAppController.testHarness' + +const tauriMock = getTauriMock() + +describe('useAppController workflows and record hydration', () => { + beforeEach(setupControllerTest) + afterEach(cleanupControllerTest) + + it('restores the saved Draft after a single-record discard supersedes an in-flight save', async () => { + const original = draftFixture({ body: '

Saved draft.

' }) + const pendingUpdate = deferred>() + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ testwareDraftCount: 1 })) + tauriMock.listDrafts.mockResolvedValueOnce([original]) + tauriMock.updateDraft.mockReturnValueOnce(pendingUpdate.promise) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('testware')) + await waitFor(() => expect(result.current.testwareDrafts).toEqual([original])) + + act(() => result.current.updateLocalDraft(original.id, { body: '

Discarded draft edit.

' })) + let savePromise!: Promise + act(() => { + savePromise = result.current.handleSaveDraft(result.current.testwareDrafts[0]) + }) + await waitFor(() => expect(tauriMock.updateDraft).toHaveBeenCalledWith(original.id, expect.objectContaining({ body: '

Discarded draft edit.

' }))) + + act(() => { + result.current.discardLocalDraft(original) + result.current.updateLocalDraft(original.id, { body: '

Newer dirty draft edit.

' }) + pendingUpdate.resolve(draftFixture({ body: '

Discarded draft edit.

' })) + }) + await act(async () => savePromise) + + expect(tauriMock.updateDraft).toHaveBeenCalledTimes(2) + expect(tauriMock.updateDraft).toHaveBeenLastCalledWith(original.id, expect.objectContaining({ body: '

Saved draft.

' })) + expect(result.current.testwareDrafts[0].body).toBe('

Newer dirty draft edit.

') + act(() => result.current.discardAllDirtyRecords()) + expect(result.current.testwareDrafts[0].body).toBe('

Saved draft.

') + }) + + it('restores the saved Finding after a single-record discard supersedes an in-flight save', async () => { + const original = findingFixture({ body: '

Saved finding.

' }) + const pendingUpdate = deferred>() + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ findingCount: 1 })) + tauriMock.listFindings.mockResolvedValueOnce([original]) + tauriMock.updateFinding.mockReturnValueOnce(pendingUpdate.promise) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('findings')) + await waitFor(() => expect(result.current.findings).toEqual([original])) + + act(() => result.current.updateLocalFinding(original.id, { body: '

Discarded finding edit.

' })) + let savePromise!: Promise + act(() => { + savePromise = result.current.handleSaveFinding(result.current.findings[0]) + }) + await waitFor(() => expect(tauriMock.updateFinding).toHaveBeenCalledWith(original.id, expect.objectContaining({ body: '

Discarded finding edit.

' }))) + + act(() => { + result.current.discardLocalFinding(original) + result.current.updateLocalFinding(original.id, { body: '

Newer dirty finding edit.

' }) + pendingUpdate.resolve(findingFixture({ body: '

Discarded finding edit.

' })) + }) + await act(async () => savePromise) + + expect(tauriMock.updateFinding).toHaveBeenCalledTimes(2) + expect(tauriMock.updateFinding).toHaveBeenLastCalledWith(original.id, expect.objectContaining({ body: '

Saved finding.

' })) + expect(result.current.findings[0].body).toBe('

Newer dirty finding edit.

') + act(() => result.current.discardAllDirtyRecords()) + expect(result.current.findings[0].body).toBe('

Saved finding.

') + }) + + it('retains Draft restoration when the save after discard fails', async () => { + const original = draftFixture({ body: '

Saved draft.

' }) + const olderUpdate = deferred>() + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ testwareDraftCount: 1 })) + tauriMock.listDrafts.mockResolvedValueOnce([original]) + tauriMock.updateDraft + .mockReturnValueOnce(olderUpdate.promise) + .mockRejectedValueOnce(new Error('newer save failed')) + .mockRejectedValueOnce(new Error('restore failed')) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('testware')) + await waitFor(() => expect(result.current.testwareDrafts).toEqual([original])) + + act(() => result.current.updateLocalDraft(original.id, { body: '

Discarded draft edit.

' })) + let olderSave!: Promise + act(() => { olderSave = result.current.handleSaveDraft(result.current.testwareDrafts[0]) }) + await waitFor(() => expect(tauriMock.updateDraft).toHaveBeenCalledTimes(1)) + + act(() => { + result.current.discardLocalDraft(original) + result.current.updateLocalDraft(original.id, { body: '

Newer draft edit.

' }) + }) + let newerSave!: Promise + act(() => { newerSave = result.current.handleSaveDraft(result.current.testwareDrafts[0]) }) + await act(async () => { await newerSave }) + expect(result.current.testwareDrafts[0].body).toBe('

Newer draft edit.

') + act(() => result.current.discardLocalDraft(original)) + expect(result.current.testwareDrafts[0].body).toBe('

Saved draft.

') + act(() => olderUpdate.resolve(draftFixture({ body: '

Discarded draft edit.

' }))) + await act(async () => { await olderSave }) + + expect(tauriMock.updateDraft.mock.calls.slice(2)).toEqual(expect.arrayContaining([ + [original.id, expect.objectContaining({ body: '

Saved draft.

' })], + ])) + expect(result.current.testwareDrafts[0].body).toBe('

Saved draft.

') + }) + + it('retains Finding restoration when the save after discard fails', async () => { + const original = findingFixture({ body: '

Saved finding.

' }) + const olderUpdate = deferred>() + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ findingCount: 1 })) + tauriMock.listFindings.mockResolvedValueOnce([original]) + tauriMock.updateFinding + .mockReturnValueOnce(olderUpdate.promise) + .mockRejectedValueOnce(new Error('newer save failed')) + .mockRejectedValueOnce(new Error('restore failed')) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('findings')) + await waitFor(() => expect(result.current.findings).toEqual([original])) + + act(() => result.current.updateLocalFinding(original.id, { body: '

Discarded finding edit.

' })) + let olderSave!: Promise + act(() => { olderSave = result.current.handleSaveFinding(result.current.findings[0]) }) + await waitFor(() => expect(tauriMock.updateFinding).toHaveBeenCalledTimes(1)) + + act(() => { + result.current.discardLocalFinding(original) + result.current.updateLocalFinding(original.id, { body: '

Newer finding edit.

' }) + }) + let newerSave!: Promise + act(() => { newerSave = result.current.handleSaveFinding(result.current.findings[0]) }) + await act(async () => { await newerSave }) + expect(result.current.findings[0].body).toBe('

Newer finding edit.

') + act(() => result.current.discardLocalFinding(original)) + expect(result.current.findings[0].body).toBe('

Saved finding.

') + act(() => olderUpdate.resolve(findingFixture({ body: '

Discarded finding edit.

' }))) + await act(async () => { await olderSave }) + + expect(tauriMock.updateFinding.mock.calls.slice(2)).toEqual(expect.arrayContaining([ + [original.id, expect.objectContaining({ body: '

Saved finding.

' })], + ])) + expect(result.current.findings[0].body).toBe('

Saved finding.

') + }) + + it('restores the saved Draft after discard-all supersedes an in-flight save', async () => { + const original = draftFixture({ body: '

Saved draft.

' }) + const pendingUpdate = deferred>() + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ testwareDraftCount: 1 })) + tauriMock.listDrafts.mockResolvedValueOnce([original]) + tauriMock.updateDraft.mockReturnValueOnce(pendingUpdate.promise) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('testware')) + await waitFor(() => expect(result.current.testwareDrafts).toEqual([original])) + + act(() => result.current.updateLocalDraft(original.id, { body: '

Discarded draft edit.

' })) + let savePromise!: Promise + act(() => { + savePromise = result.current.handleSaveDraft(result.current.testwareDrafts[0]) + }) + await waitFor(() => expect(tauriMock.updateDraft).toHaveBeenCalledTimes(1)) + + act(() => { + result.current.discardAllDirtyRecords() + pendingUpdate.resolve(draftFixture({ body: '

Discarded draft edit.

' })) + }) + await act(async () => savePromise) + + expect(tauriMock.updateDraft).toHaveBeenCalledTimes(2) + expect(tauriMock.updateDraft).toHaveBeenLastCalledWith(original.id, expect.objectContaining({ body: '

Saved draft.

' })) + expect(result.current.testwareDrafts[0].body).toBe('

Saved draft.

') + }) + + it('restores the saved Finding after discard-all supersedes an in-flight save', async () => { + const original = findingFixture({ body: '

Saved finding.

' }) + const pendingUpdate = deferred>() + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ findingCount: 1 })) + tauriMock.listFindings.mockResolvedValueOnce([original]) + tauriMock.updateFinding.mockReturnValueOnce(pendingUpdate.promise) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('findings')) + await waitFor(() => expect(result.current.findings).toEqual([original])) + + act(() => result.current.updateLocalFinding(original.id, { body: '

Discarded finding edit.

' })) + let savePromise!: Promise + act(() => { + savePromise = result.current.handleSaveFinding(result.current.findings[0]) + }) + await waitFor(() => expect(tauriMock.updateFinding).toHaveBeenCalledTimes(1)) + + act(() => { + result.current.discardAllDirtyRecords() + pendingUpdate.resolve(findingFixture({ body: '

Discarded finding edit.

' })) + }) + await act(async () => savePromise) + + expect(tauriMock.updateFinding).toHaveBeenCalledTimes(2) + expect(tauriMock.updateFinding).toHaveBeenLastCalledWith(original.id, expect.objectContaining({ body: '

Saved finding.

' })) + expect(result.current.findings[0].body).toBe('

Saved finding.

') + }) + + it('does not dispatch a stale Draft save when discard wins during image materialization', async () => { + const original = draftFixture({ body: '

Saved draft.

' }) + const pendingImport = deferred<{ id: string; filename: string }>() + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ testwareDraftCount: 1 })) + tauriMock.listDrafts.mockResolvedValueOnce([original]) + tauriMock.importClipboardScreenshot.mockReturnValueOnce(pendingImport.promise) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('testware')) + await waitFor(() => expect(result.current.testwareDrafts).toEqual([original])) + + act(() => result.current.updateLocalDraft(original.id, { body: '

Evidence

' })) + let savePromise!: Promise + act(() => { + savePromise = result.current.handleSaveDraft(result.current.testwareDrafts[0]) + }) + await waitFor(() => expect(tauriMock.importClipboardScreenshot).toHaveBeenCalledTimes(1)) + + act(() => { + result.current.discardLocalDraft(original) + pendingImport.resolve({ id: 'attachment-stale-draft', filename: 'Evidence.png' }) + }) + await act(async () => savePromise) + + expect(tauriMock.updateDraft).not.toHaveBeenCalled() + expect(tauriMock.deleteAttachment).toHaveBeenCalledWith('attachment-stale-draft') + expect(result.current.testwareDrafts[0].body).toBe('

Saved draft.

') + }) + + it('does not dispatch a stale Finding save when discard-all wins during image materialization', async () => { + const original = findingFixture({ body: '

Saved finding.

' }) + const pendingImport = deferred<{ id: string; filename: string }>() + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ findingCount: 1 })) + tauriMock.listFindings.mockResolvedValueOnce([original]) + tauriMock.importClipboardScreenshot.mockReturnValueOnce(pendingImport.promise) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('findings')) + await waitFor(() => expect(result.current.findings).toEqual([original])) + + act(() => result.current.updateLocalFinding(original.id, { body: '

Evidence

' })) + let savePromise!: Promise + act(() => { + savePromise = result.current.handleSaveFinding(result.current.findings[0]) + }) + await waitFor(() => expect(tauriMock.importClipboardScreenshot).toHaveBeenCalledTimes(1)) + + act(() => { + result.current.discardAllDirtyRecords() + pendingImport.resolve({ id: 'attachment-stale-finding', filename: 'Evidence.png' }) + }) + await act(async () => savePromise) + + expect(tauriMock.updateFinding).not.toHaveBeenCalled() + expect(tauriMock.deleteAttachment).toHaveBeenCalledWith('attachment-stale-finding') + expect(result.current.findings[0].body).toBe('

Saved finding.

') + }) + + it('keeps an imported image retained by a newer Draft edit after the older write resolves', async () => { + const original = draftFixture({ id: 'draft-retained', body: '

Saved draft.

' }) + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ testwareDraftCount: 1 })) + tauriMock.listDrafts.mockResolvedValueOnce([original]) + tauriMock.importClipboardScreenshot.mockResolvedValueOnce({ id: 'attachment-retained', filename: 'retained.png' }) + const olderWrite = deferred>() + tauriMock.updateDraft.mockReturnValueOnce(olderWrite.promise) + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + act(() => result.current.setActiveView('testware')) + await waitFor(() => expect(result.current.testwareDrafts).toEqual([original])) + act(() => result.current.updateLocalDraft(original.id, { + body: '

Older Draft

retained', + })) + + let savePromise!: Promise + act(() => { savePromise = result.current.handleSaveDraft(result.current.testwareDrafts[0]) }) + await waitFor(() => expect(tauriMock.updateDraft).toHaveBeenCalledTimes(1)) + act(() => result.current.updateLocalDraft(original.id, { + body: '

Newer Draft

retained', + })) + await act(async () => { + olderWrite.resolve(draftFixture({ + id: original.id, + body: '

Older Draft

retained', + })) + await savePromise + }) + + expect(tauriMock.deleteAttachment).not.toHaveBeenCalledWith('attachment-retained') + expect(result.current.testwareDrafts[0].body).toContain('attachment-retained') + }) + + it('compensates a late generation image write after a newer undo succeeds', async () => { + const originalBody = '

Original Note.

Evidence

' + tauriMock.openSessionNoteState.mockResolvedValueOnce(sessionNoteStateFixture({ + noteEntry: entryFixture({ body: originalBody }), + })) + const lateImageWrite = deferred>() + tauriMock.updateEntry.mockReturnValueOnce(lateImageWrite.promise) + tauriMock.startAiActionJob.mockImplementationOnce(async (_request: unknown, onEvent: (event: unknown) => void) => { + onEvent({ + type: 'completed', + job_id: 'job-summary-race', + status: generationStatusFixture({ jobId: 'job-summary-race', action: 'summary', state: 'completed' }), + result: { + generationContext: { id: 'context-summary-race', sessionId: 'session-1', createdAt: '2026-06-24T10:00:00.000Z' }, + aiRun: { + id: 'run-summary-race', sessionId: 'session-1', generationContextId: 'context-summary-race', + provider: 'codex_cli', model: 'default', reasoningEffort: null, promptVersion: 'summary-v1', + status: 'completed', errorMessage: null, createdAt: '2026-06-24T10:00:00.000Z', completedAt: '2026-06-24T10:00:00.000Z', + }, + draft: null, + finding: null, + noteEntry: entryFixture({ body: '

Generated Summary.

' }), + }, + }) + return { jobId: 'job-summary-race', status: generationStatusFixture({ jobId: 'job-summary-race', action: 'summary', state: 'completed' }) } + }) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + await act(async () => { await result.current.handleAiAction('summary') }) + await waitFor(() => expect(tauriMock.updateEntry).toHaveBeenCalledTimes(1)) + + await act(async () => { await result.current.handleUndoLatestNoteGeneration() }) + expect(tauriMock.updateEntry).toHaveBeenCalledTimes(2) + + await act(async () => { + lateImageWrite.resolve(entryFixture({ body: '

Generated Summary.

' })) + await lateImageWrite.promise + }) + await waitFor(() => expect(tauriMock.updateEntry).toHaveBeenCalledTimes(3)) + expect(tauriMock.updateEntry).toHaveBeenLastCalledWith('entry-1', expect.objectContaining({ + body: expect.stringContaining('Original Note.'), + })) + expect(result.current.sessionSaveState).toBe('saved') + }) + + it('preserves a newer dirty Draft when generated canonicalization fails', async () => { + const generated = draftFixture({ id: 'draft-canonical-failure', body: '

Generated Draft.

' }) + let rejectCanonicalization!: (cause?: unknown) => void + tauriMock.updateDraft.mockReturnValueOnce(new Promise((_resolve, reject) => { + rejectCanonicalization = reject + })) + tauriMock.startAiActionJob.mockImplementationOnce(async (_request: unknown, onEvent: (event: unknown) => void) => { + onEvent(completedRecordGenerationEvent({ draft: generated })) + return { jobId: 'job-draft-canonical-failure', status: generationStatusFixture({ jobId: 'job-draft-canonical-failure', action: 'testware', state: 'completed' }) } + }) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + await act(async () => { await result.current.handleAiAction('testware') }) + act(() => result.current.updateLocalDraft(generated.id, { body: '

Newer dirty Draft.

' })) + act(() => rejectCanonicalization(new Error('canonicalization failed'))) + + await waitFor(() => expect(result.current.error).toContain('canonicalization failed')) + expect(result.current.testwareDrafts[0].body).toBe('

Newer dirty Draft.

') + }) + + it('compensates late generated Draft canonicalization to a newer successful save', async () => { + const canonicalization = deferred>() + const generated = draftFixture({ id: 'draft-canonical-race', body: '

Generated Draft.

' }) + const newer = draftFixture({ id: generated.id, body: '

Newer saved Draft.

' }) + tauriMock.updateDraft + .mockReturnValueOnce(canonicalization.promise) + .mockResolvedValueOnce(newer) + .mockRejectedValueOnce(new Error('Draft compensation offline')) + .mockResolvedValueOnce(newer) + tauriMock.startAiActionJob.mockImplementationOnce(async (_request: unknown, onEvent: (event: unknown) => void) => { + onEvent(completedRecordGenerationEvent({ draft: generated })) + return { jobId: 'job-draft-race', status: generationStatusFixture({ jobId: 'job-draft-race', action: 'testware', state: 'completed' }) } + }) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + await act(async () => { await result.current.handleAiAction('testware') }) + act(() => result.current.updateLocalDraft(generated.id, { body: newer.body })) + await act(async () => { await result.current.handleSaveDraft(result.current.testwareDrafts[0]) }) + + await act(async () => { + canonicalization.resolve(generated) + await canonicalization.promise + }) + await waitFor(() => expect(tauriMock.updateDraft).toHaveBeenCalledTimes(3)) + expect(result.current.error).toContain('Draft compensation offline') + await act(async () => { expect(await result.current.saveDirtyRecordsNow()).toBe(true) }) + expect(tauriMock.updateDraft).toHaveBeenCalledTimes(4) + expect(tauriMock.updateDraft).toHaveBeenLastCalledWith(generated.id, expect.objectContaining({ body: newer.body })) + expect(result.current.testwareDrafts[0].body).toBe(newer.body) + }) + + it('compensates late generated Finding canonicalization to a newer successful save', async () => { + const canonicalization = deferred>() + const generated = findingFixture({ id: 'finding-canonical-race', body: '

Generated Finding.

' }) + const newer = findingFixture({ id: generated.id, body: '

Newer saved Finding.

' }) + tauriMock.updateFinding + .mockReturnValueOnce(canonicalization.promise) + .mockResolvedValueOnce(newer) + .mockRejectedValueOnce(new Error('Finding compensation offline')) + .mockResolvedValueOnce(newer) + tauriMock.startAiActionJob.mockImplementationOnce(async (_request: unknown, onEvent: (event: unknown) => void) => { + onEvent(completedRecordGenerationEvent({ finding: generated })) + return { jobId: 'job-finding-race', status: generationStatusFixture({ jobId: 'job-finding-race', action: 'finding', state: 'completed' }) } + }) + + const { result } = renderHook(() => useAppController()) + await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) + await act(async () => { await result.current.handleAiAction('finding') }) + act(() => result.current.updateLocalFinding(generated.id, { body: newer.body })) + await act(async () => { await result.current.handleSaveFinding(result.current.findings[0]) }) + + await act(async () => { + canonicalization.resolve(generated) + await canonicalization.promise + }) + await waitFor(() => expect(tauriMock.updateFinding).toHaveBeenCalledTimes(3)) + expect(result.current.error).toContain('Finding compensation offline') + await act(async () => { expect(await result.current.saveDirtyRecordsNow()).toBe(true) }) + expect(tauriMock.updateFinding).toHaveBeenCalledTimes(4) + expect(tauriMock.updateFinding).toHaveBeenLastCalledWith(generated.id, expect.objectContaining({ body: newer.body })) + expect(result.current.findings[0].body).toBe(newer.body) + }) +}) + +function completedRecordGenerationEvent({ + draft = null, + finding = null, +}: { + draft?: ReturnType | null + finding?: ReturnType | null +}) { + const action: 'testware' | 'finding' = draft ? 'testware' : 'finding' + return { + type: 'completed', + job_id: `job-${action}-race`, + status: generationStatusFixture({ jobId: `job-${action}-race`, action, state: 'completed' }), + result: { + generationContext: { id: `context-${action}-race`, sessionId: 'session-1', createdAt: '2026-06-24T10:00:00.000Z' }, + aiRun: { + id: `run-${action}-race`, sessionId: 'session-1', generationContextId: `context-${action}-race`, + provider: 'codex_cli', model: 'default', reasoningEffort: null, promptVersion: `${action}-v1`, + status: 'completed', errorMessage: null, createdAt: '2026-06-24T10:00:00.000Z', completedAt: '2026-06-24T10:00:00.000Z', + }, + draft, + finding, + noteEntry: null, + }, + } +} diff --git a/frontend/src/app/useAppController.workflows.test.ts b/frontend/src/app/useAppController.workflows.test.ts index 6eca71b..36433e6 100644 --- a/frontend/src/app/useAppController.workflows.test.ts +++ b/frontend/src/app/useAppController.workflows.test.ts @@ -322,157 +322,4 @@ describe('useAppController workflows and record hydration', () => { expect(result.current.testwareDrafts.find((draft) => draft.id === 'draft-dirty')?.body).toBe('

Unsaved local draft.

') }) - - it('does not let generated Draft canonicalization overwrite a dirty local edit', async () => { - let resolveUpdateDraft: (draft: ReturnType) => void = () => {} - tauriMock.updateDraft.mockReturnValueOnce( - new Promise((resolve) => { - resolveUpdateDraft = resolve - }), - ) - tauriMock.startAiActionJob.mockImplementationOnce(async (_request: unknown, onEvent: (event: unknown) => void) => { - onEvent({ - type: 'completed', - job_id: 'job-1', - status: generationStatusFixture({ jobId: 'job-1', action: 'testware', state: 'completed' }), - result: { - generationContext: { id: 'context-1', sessionId: 'session-1', createdAt: '2026-06-24T10:00:00.000Z' }, - aiRun: { - id: 'run-1', - sessionId: 'session-1', - generationContextId: 'context-1', - provider: 'codex_cli', - model: 'default', - reasoningEffort: null, - promptVersion: 'testware-v4', - status: 'completed', - errorMessage: null, - createdAt: '2026-06-24T10:00:00.000Z', - completedAt: '2026-06-24T10:00:00.000Z', - }, - draft: draftFixture({ id: 'draft-generated', sessionId: 'session-1', body: '

Generated body.

' }), - finding: null, - noteEntry: null, - }, - }) - return { jobId: 'job-1', status: generationStatusFixture({ jobId: 'job-1', action: 'testware', state: 'completed' }) } - }) - - const { result } = renderHook(() => useAppController()) - - await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) - await act(async () => { - await result.current.handleAiAction('testware') - }) - - act(() => { - result.current.updateLocalDraft('draft-generated', { body: '

Unsaved edit after generation.

' }) - resolveUpdateDraft(draftFixture({ id: 'draft-generated', sessionId: 'session-1', body: '

Canonical server body.

' })) - }) - await act(async () => { - await Promise.resolve() - }) - - expect(result.current.testwareDrafts.find((draft) => draft.id === 'draft-generated')?.body).toBe('

Unsaved edit after generation.

') - }) - - it('materializes inline data images before saving a dirty Draft', async () => { - tauriMock.openSessionNoteState.mockResolvedValueOnce( - sessionNoteStateFixture({ - testwareDraftCount: 1, - }), - ) - tauriMock.listDrafts.mockResolvedValueOnce([draftFixture({ id: 'draft-inline', sessionId: 'session-1', body: '

Persisted draft.

' })]) - - const { result } = renderHook(() => useAppController()) - - await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) - act(() => { - result.current.setActiveView('testware') - }) - await waitFor(() => expect(result.current.testwareDrafts.map((draft) => draft.id)).toEqual(['draft-inline'])) - - act(() => { - result.current.updateLocalDraft('draft-inline', { body: '

Evidence

' }) - }) - await act(async () => { - await result.current.handleSaveDraft(result.current.testwareDrafts[0]) - }) - - expect(tauriMock.importClipboardScreenshot).toHaveBeenCalledWith({ - sessionId: 'session-1', - entryId: null, - filename: 'Evidence.png', - dataUrl: 'data:image/png;base64,AAAA', - }) - expect(tauriMock.updateDraft).toHaveBeenCalledWith( - 'draft-inline', - expect.objectContaining({ - body: expect.stringContaining('qa-scribe-attachment://attachment-1'), - }), - ) - expect(tauriMock.updateDraft).toHaveBeenCalledWith( - 'draft-inline', - expect.objectContaining({ - body: expect.not.stringContaining('data:image/png'), - }), - ) - }) - - it('keeps a Draft dirty when another edit happens while save is in flight', async () => { - tauriMock.openSessionNoteState.mockResolvedValueOnce( - sessionNoteStateFixture({ - testwareDraftCount: 1, - }), - ) - tauriMock.listDrafts.mockResolvedValueOnce([draftFixture({ id: 'draft-race', sessionId: 'session-1', body: '

Persisted draft.

' })]) - let resolveUpdateDraft: (draft: ReturnType) => void = () => {} - tauriMock.updateDraft.mockReturnValueOnce( - new Promise((resolve) => { - resolveUpdateDraft = resolve - }), - ) - - const { result } = renderHook(() => useAppController()) - - await waitFor(() => expect(result.current.activeSession?.id).toBe('session-1')) - act(() => { - result.current.setActiveView('testware') - }) - await waitFor(() => expect(result.current.testwareDrafts.map((draft) => draft.id)).toEqual(['draft-race'])) - - act(() => { - result.current.updateLocalDraft('draft-race', { body: '

First unsaved edit.

' }) - }) - let savePromise!: Promise - act(() => { - savePromise = result.current.handleSaveDraft(result.current.testwareDrafts[0]) - }) - await waitFor(() => expect(tauriMock.updateDraft).toHaveBeenCalledWith('draft-race', expect.objectContaining({ body: '

First unsaved edit.

' }))) - - act(() => { - result.current.updateLocalDraft('draft-race', { body: '

Edit typed while saving.

' }) - resolveUpdateDraft(draftFixture({ id: 'draft-race', sessionId: 'session-1', body: '

First unsaved edit.

' })) - }) - await act(async () => { - await savePromise - }) - - expect(result.current.testwareDrafts.find((draft) => draft.id === 'draft-race')?.body).toBe('

Edit typed while saving.

') - - const otherSession = sessionFixture({ id: 'session-2', title: 'Other session' }) - tauriMock.openSessionNoteState.mockResolvedValueOnce( - sessionNoteStateFixture({ - session: otherSession, - noteEntry: entryFixture({ id: 'entry-2', sessionId: 'session-2' }), - }), - ) - await act(async () => { - await result.current.openSession(otherSession) - }) - - expect(tauriMock.updateDraft).toHaveBeenLastCalledWith('draft-race', expect.objectContaining({ body: '

Edit typed while saving.

' })) - expect(result.current.activeSession?.id).toBe('session-2') - }) - }) diff --git a/frontend/src/app/useAppStartup.ts b/frontend/src/app/useAppStartup.ts index 5e5d537..0d1ed25 100644 --- a/frontend/src/app/useAppStartup.ts +++ b/frontend/src/app/useAppStartup.ts @@ -9,6 +9,7 @@ const STARTUP_SESSION_LIMIT = 50 type UseAppStartupOptions = { loadSettings: (settings: AppSettings) => void openSession: (session: Session, showNotice?: boolean) => Promise + captureActiveJobs: () => Promise reconcileActiveJobs: () => Promise loadProviderStatus: () => Promise refreshProviderStatus: () => Promise @@ -43,6 +44,7 @@ export function useAppStartup(options: UseAppStartupOptions) { const { loadSettings, openSession, + captureActiveJobs, reconcileActiveJobs, setSessions, setSessionLibraryComplete, @@ -65,7 +67,11 @@ export function useAppStartup(options: UseAppStartupOptions) { startupMeasure('boot-to-sessions-loaded', 'boot-start', 'sessions-loaded') return sessions }) - const [settings, sessions] = await Promise.all([settingsRequest, sessionsRequest]) + // Capture recovered Summary jobs before hydrating the startup Session. + // Otherwise a completion can leave the just-opened stale Note eligible for + // autosave before the frontend learns that reconciliation is required. + const activeJobsRequest = captureActiveJobs() + const [settings, sessions] = await Promise.all([settingsRequest, sessionsRequest, activeJobsRequest]) loadSettings(settings) setSessions(sessions) setSessionLibraryComplete(sessions.length < STARTUP_SESSION_LIMIT) diff --git a/frontend/src/app/useOutputLibraries.ts b/frontend/src/app/useOutputLibraries.ts index 995858d..e756459 100644 --- a/frontend/src/app/useOutputLibraries.ts +++ b/frontend/src/app/useOutputLibraries.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { listDraftLibrary, listFindingLibrary, @@ -17,41 +17,63 @@ export function useOutputLibraries(activeView: MainView) { const [findingLibraryState, setFindingLibraryState] = useState('idle') const [draftLibraryError, setDraftLibraryError] = useState(null) const [findingLibraryError, setFindingLibraryError] = useState(null) + const draftLoadEpochRef = useRef(0) + const findingLoadEpochRef = useRef(0) const loadDraftLibrary = useCallback(async () => { + const loadEpoch = ++draftLoadEpochRef.current setDraftLibraryState('loading') setDraftLibraryError(null) try { - setDraftLibrary(await listDraftLibrary()) + const loaded = await listDraftLibrary() + if (draftLoadEpochRef.current !== loadEpoch) return + setDraftLibrary(loaded) setDraftLibraryState('ready') } catch (cause) { + if (draftLoadEpochRef.current !== loadEpoch) return setDraftLibraryError(formatError(cause)) setDraftLibraryState('error') } }, []) const loadFindingLibrary = useCallback(async () => { + const loadEpoch = ++findingLoadEpochRef.current setFindingLibraryState('loading') setFindingLibraryError(null) try { - setFindingLibrary(await listFindingLibrary()) + const loaded = await listFindingLibrary() + if (findingLoadEpochRef.current !== loadEpoch) return + setFindingLibrary(loaded) setFindingLibraryState('ready') } catch (cause) { + if (findingLoadEpochRef.current !== loadEpoch) return setFindingLibraryError(formatError(cause)) setFindingLibraryState('error') } }, []) useEffect(() => { - if (activeView !== 'testware-library') return + if (activeView !== 'testware-library') { + draftLoadEpochRef.current += 1 + return + } const timeout = window.setTimeout(() => void loadDraftLibrary(), 0) - return () => window.clearTimeout(timeout) + return () => { + window.clearTimeout(timeout) + draftLoadEpochRef.current += 1 + } }, [activeView, loadDraftLibrary]) useEffect(() => { - if (activeView !== 'findings-library') return + if (activeView !== 'findings-library') { + findingLoadEpochRef.current += 1 + return + } const timeout = window.setTimeout(() => void loadFindingLibrary(), 0) - return () => window.clearTimeout(timeout) + return () => { + window.clearTimeout(timeout) + findingLoadEpochRef.current += 1 + } }, [activeView, loadFindingLibrary]) return { diff --git a/frontend/src/app/usePendingChangeProtection.ts b/frontend/src/app/usePendingChangeProtection.ts index a19c9f7..3d1a11d 100644 --- a/frontend/src/app/usePendingChangeProtection.ts +++ b/frontend/src/app/usePendingChangeProtection.ts @@ -9,9 +9,11 @@ type UsePendingChangeProtectionOptions = { noteBody: RichEditorDocument savedTitleRef: MutableRefObject savedBodyRef: MutableRefObject + pendingRecoveredSummaryDecision: boolean dirtyDraftIdsRef: MutableRefObject> dirtyFindingIdsRef: MutableRefObject> settingsDirty: boolean + hasPendingCompensations: () => boolean savePendingChanges: () => Promise } @@ -22,12 +24,14 @@ export function usePendingChangeProtection({ noteBody, savedTitleRef, savedBodyRef, + pendingRecoveredSummaryDecision, dirtyDraftIdsRef, dirtyFindingIdsRef, settingsDirty, + hasPendingCompensations, savePendingChanges, }: UsePendingChangeProtectionOptions) { - const pendingNoteStateRef = useRef({ hasActiveSession, hasNoteEntry, sessionTitle, noteBody }) + const pendingNoteStateRef = useRef({ hasActiveSession, hasNoteEntry, sessionTitle, noteBody, pendingRecoveredSummaryDecision }) const savePendingChangesRef = useRef(savePendingChanges) const closeRequestInFlightRef = useRef(false) @@ -36,16 +40,18 @@ export function usePendingChangeProtection({ }, [savePendingChanges]) useEffect(() => { - pendingNoteStateRef.current = { hasActiveSession, hasNoteEntry, sessionTitle, noteBody } - }, [hasActiveSession, hasNoteEntry, sessionTitle, noteBody]) + pendingNoteStateRef.current = { hasActiveSession, hasNoteEntry, sessionTitle, noteBody, pendingRecoveredSummaryDecision } + }, [hasActiveSession, hasNoteEntry, sessionTitle, noteBody, pendingRecoveredSummaryDecision]) const hasPendingChanges = useCallback(() => { const current = pendingNoteStateRef.current - const trimmedTitle = current.sessionTitle.trim() - const titleDirty = Boolean(current.hasActiveSession && trimmedTitle && trimmedTitle !== savedTitleRef.current) + const titleDirty = Boolean( + current.hasActiveSession + && current.sessionTitle !== savedTitleRef.current, + ) const bodyDirty = Boolean(current.hasNoteEntry && serializeRichEditorDocument(current.noteBody) !== savedBodyRef.current) - return titleDirty || bodyDirty || settingsDirty || dirtyDraftIdsRef.current.size > 0 || dirtyFindingIdsRef.current.size > 0 - }, [dirtyDraftIdsRef, dirtyFindingIdsRef, savedBodyRef, savedTitleRef, settingsDirty]) + return current.pendingRecoveredSummaryDecision || titleDirty || bodyDirty || settingsDirty || hasPendingCompensations() || dirtyDraftIdsRef.current.size > 0 || dirtyFindingIdsRef.current.size > 0 + }, [dirtyDraftIdsRef, dirtyFindingIdsRef, hasPendingCompensations, savedBodyRef, savedTitleRef, settingsDirty]) useEffect(() => { function handleBeforeUnload(event: BeforeUnloadEvent) { diff --git a/frontend/src/app/useRecordHydration.ts b/frontend/src/app/useRecordHydration.ts index 5ea133b..571b0b6 100644 --- a/frontend/src/app/useRecordHydration.ts +++ b/frontend/src/app/useRecordHydration.ts @@ -9,6 +9,7 @@ type UseRecordHydrationOptions = { } export type RecordLoadState = 'idle' | 'loading' | 'ready' | 'error' +export type RecordLoadSuspension = { draftVersion: number; findingVersion: number } export function useRecordHydration({ activeSessionId, activeView }: UseRecordHydrationOptions) { const [drafts, setDrafts] = useState([]) @@ -32,11 +33,21 @@ export function useRecordHydration({ activeSessionId, activeView }: UseRecordHyd const findingLoadVersionRef = useRef(0) const activeSessionIdRef = useRef(activeSessionId) - const invalidateRecordLoads = useCallback(() => { + const invalidateDraftLoads = useCallback(() => { draftLoadVersionRef.current += 1 + setDraftLoadState((current) => current === 'loading' ? 'ready' : current) + }, []) + + const invalidateFindingLoads = useCallback(() => { findingLoadVersionRef.current += 1 + setFindingLoadState((current) => current === 'loading' ? 'ready' : current) }, []) + const invalidateRecordLoads = useCallback(() => { + invalidateDraftLoads() + invalidateFindingLoads() + }, [invalidateDraftLoads, invalidateFindingLoads]) + const resetRecordHydration = useCallback(() => { invalidateRecordLoads() draftsSessionIdRef.current = null @@ -107,6 +118,31 @@ export function useRecordHydration({ activeSessionId, activeView }: UseRecordHyd return nextFindings }, []) + const suspendRecordLoads = useCallback((): RecordLoadSuspension => { + invalidateRecordLoads() + return { + draftVersion: draftLoadVersionRef.current, + findingVersion: findingLoadVersionRef.current, + } + }, [invalidateRecordLoads]) + + const restoreRecordLoads = useCallback(async (suspension: RecordLoadSuspension): Promise => { + if ( + draftLoadVersionRef.current !== suspension.draftVersion + || findingLoadVersionRef.current !== suspension.findingVersion + || !activeSessionId + ) return + try { + if (activeView === 'testware') { + await loadDraftsForSession(activeSessionId, { force: true }) + } else if (activeView === 'findings') { + await loadFindingsForSession(activeSessionId, { force: true }) + } + } catch { + // The loader owns its error state and retry UI. + } + }, [activeSessionId, activeView, loadDraftsForSession, loadFindingsForSession]) + useEffect(() => { activeSessionIdRef.current = activeSessionId }, [activeSessionId]) @@ -150,8 +186,11 @@ export function useRecordHydration({ activeSessionId, activeView }: UseRecordHyd setFindings, setTestwareDraftCount, setFindingCount, - invalidateRecordLoads, + invalidateDraftLoads, + invalidateFindingLoads, resetRecordHydration, + suspendRecordLoads, + restoreRecordLoads, loadDraftsForSession, loadFindingsForSession, } diff --git a/frontend/src/bindings.ts b/frontend/src/bindings.ts index 3c554e4..afd7cc4 100644 --- a/frontend/src/bindings.ts +++ b/frontend/src/bindings.ts @@ -39,6 +39,7 @@ export const commands = { listActiveAiActionJobs: () => __TAURI_INVOKE("list_active_ai_action_jobs"), cancelAiActionJob: (jobId: string) => __TAURI_INVOKE("cancel_ai_action_job", { jobId }), importClipboardScreenshot: (sessionId: string, entryId: string | null, filename: string, dataUrl: string) => __TAURI_INVOKE("import_clipboard_screenshot", { sessionId, entryId, filename, dataUrl }), + deleteAttachment: (attachmentId: string) => __TAURI_INVOKE("delete_attachment", { attachmentId }), getProviderStatus: () => __TAURI_INVOKE("get_provider_status"), refreshProviderStatus: () => __TAURI_INVOKE("refresh_provider_status"), getAttachmentPreviewDataUrl: (attachmentId: string) => __TAURI_INVOKE("get_attachment_preview_data_url", { attachmentId }), diff --git a/frontend/src/editor/RichTextEditor.test.tsx b/frontend/src/editor/RichTextEditor.test.tsx index bb7501f..c49e881 100644 --- a/frontend/src/editor/RichTextEditor.test.tsx +++ b/frontend/src/editor/RichTextEditor.test.tsx @@ -1,5 +1,6 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('../tauri', () => ({ getAttachmentPreviewDataUrl: vi.fn(), @@ -8,8 +9,12 @@ vi.mock('../tauri', () => ({ MANAGED_ATTACHMENT_PROTOCOL: 'qa-scribe-attachment://', })) +import { getAttachmentPreviewDataUrl } from '../tauri' import { FormatToolbar, RichTextEditor, type RichEditorImageUpload } from './RichTextEditor' import { emptyRichEditorDocument, richEditorDocumentFromHtml, richEditorDocumentToHtml, type RichEditorDocument } from './editorDocument' +import { managedAttachmentImageHtml } from './editorHtml' + +const getAttachmentPreviewDataUrlMock = vi.mocked(getAttachmentPreviewDataUrl) beforeAll(() => { const rect = { @@ -38,6 +43,10 @@ beforeAll(() => { }) describe('RichTextEditor toolbar', () => { + beforeEach(() => { + getAttachmentPreviewDataUrlMock.mockReset() + }) + afterEach(() => { cleanup() vi.restoreAllMocks() @@ -83,6 +92,104 @@ describe('RichTextEditor toolbar', () => { expect(screen.queryByRole('textbox', { name: 'IRIS testware preview' })).toBeNull() }) + it('does not re-read a managed preview while the user types', async () => { + getAttachmentPreviewDataUrlMock.mockResolvedValue('data:image/png;base64,AAAA') + const onChange = vi.fn() + const user = userEvent.setup() + render( + Before

${managedAttachmentImageHtml('attachment-1', 'evidence.png')}

`)} + onChange={onChange} + />, + ) + + const editor = await screen.findByRole('textbox', { name: 'Note body' }) + await waitFor(() => expect(getAttachmentPreviewDataUrlMock).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(editor.querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,AAAA')) + + placeCursorAtEnd(editor) + await user.keyboard(' typed') + await waitFor(() => expect(lastChangeHtml(onChange)).toContain('Before typed')) + await Promise.resolve() + + expect(getAttachmentPreviewDataUrlMock).toHaveBeenCalledTimes(1) + }) + + it('retries failed previews on backoff without using editor updates as retries', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + getAttachmentPreviewDataUrlMock + .mockRejectedValueOnce(new Error('first transient failure')) + .mockRejectedValueOnce(new Error('second transient failure')) + .mockResolvedValueOnce('data:image/png;base64,AAAA') + const initialValue = richEditorDocumentFromHtml(`

Before

${managedAttachmentImageHtml('attachment-1', 'evidence.png')}

`) + const { rerender } = render() + + try { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + const editor = screen.getByRole('textbox', { name: 'Note body' }) + expect(getAttachmentPreviewDataUrlMock).toHaveBeenCalledTimes(1) + + rerender( + After typing

${managedAttachmentImageHtml('attachment-1', 'evidence.png')}

`)} + />, + ) + await act(async () => { + await Promise.resolve() + }) + expect(getAttachmentPreviewDataUrlMock).toHaveBeenCalledTimes(1) + + await act(async () => { + await vi.advanceTimersByTimeAsync(250) + }) + expect(getAttachmentPreviewDataUrlMock).toHaveBeenCalledTimes(2) + + rerender() + await act(async () => { + await vi.advanceTimersByTimeAsync(999) + }) + expect(getAttachmentPreviewDataUrlMock).toHaveBeenCalledTimes(2) + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(getAttachmentPreviewDataUrlMock).toHaveBeenCalledTimes(3) + expect(editor.querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,AAAA') + } finally { + vi.useRealTimers() + } + }) + + it('cancels scheduled preview retries when the editor unmounts', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + getAttachmentPreviewDataUrlMock.mockRejectedValue(new Error('transient failure')) + const { unmount } = render( + ${managedAttachmentImageHtml('attachment-1', 'evidence.png')}

`)} />, + ) + + try { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + expect(getAttachmentPreviewDataUrlMock).toHaveBeenCalledTimes(1) + + unmount() + await act(async () => { + await vi.runAllTimersAsync() + }) + + expect(getAttachmentPreviewDataUrlMock).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + it('formats selected content and can toggle the mark off again', async () => { const onChange = vi.fn() render( @@ -312,6 +419,7 @@ describe('RichTextEditor toolbar', () => { expect(onUploadImage).toHaveBeenCalledTimes(1) const upload = onUploadImage.mock.calls[0]?.[0] + expect(upload?.editorId).toBe('second-editor') expect(upload?.file).toBe(file) expect(upload?.insertImage).toEqual(expect.any(Function)) diff --git a/frontend/src/editor/RichTextEditor.tsx b/frontend/src/editor/RichTextEditor.tsx index 97431e8..d511ff6 100644 --- a/frontend/src/editor/RichTextEditor.tsx +++ b/frontend/src/editor/RichTextEditor.tsx @@ -1,7 +1,13 @@ import { useEffect, useId, useRef, useState, type ChangeEvent, type FormEvent, type MouseEvent } from 'react' import { EditorContent, useEditor, type Editor } from '@tiptap/react' import { Bold, ImageIcon, Italic, Link2, List, ListChecks, type LucideIcon } from 'lucide-react' -import { isSafeEditorLinkUrl, managedAttachmentProtocol, hydrateManagedAttachmentPreviews } from './editorHtml' +import { + createManagedAttachmentPreviewCache, + hydrateManagedAttachmentPreviews, + isSafeEditorLinkUrl, + managedAttachmentProtocol, + type ManagedAttachmentPreviewCache, +} from './editorHtml' import { richTextEditorExtensions } from './editorExtensions' import { normalizeRichEditorDocument, richEditorDocumentsEqual, type RichEditorDocument } from './editorDocument' import { @@ -114,8 +120,8 @@ export function FormatToolbar({ editorId, onUploadImage }: FormatToolbarProps) { event.currentTarget.value = '' const insertImage = uploadInserterRef.current uploadInserterRef.current = null - if (!file || !insertImage || !onUploadImage) return - void onUploadImage({ file, insertImage }) + if (!file || !insertImage || !onUploadImage || !editorId) return + void onUploadImage({ editorId, file, insertImage }) } return ( @@ -211,12 +217,23 @@ export function RichTextEditor({ editorId, }: RichTextEditorProps) { const previewLoadRef = useRef(0) + const previewRetryTimerRef = useRef(null) + const [previewCache] = useState(() => createManagedAttachmentPreviewCache()) const onChangeRef = useRef(onChange) useEffect(() => { onChangeRef.current = onChange }, [onChange]) + useEffect( + () => () => { + previewLoadRef.current += 1 + if (previewRetryTimerRef.current !== null) window.clearTimeout(previewRetryTimerRef.current) + previewCache.clear() + }, + [previewCache], + ) + const editor = useEditor( { extensions: richTextEditorExtensions(placeholder), @@ -237,7 +254,7 @@ export function RichTextEditor({ onUpdate: ({ editor: updatedEditor }) => { const nextValue = normalizeRichEditorDocument({ schemaVersion: 1, doc: updatedEditor.getJSON() }) onChangeRef.current?.(nextValue) - queueManagedPreviewHydration(updatedEditor, previewLoadRef) + queueManagedPreviewHydration(updatedEditor, previewLoadRef, previewRetryTimerRef, previewCache) notifyRichEditorRegistry() }, onSelectionUpdate: () => notifyRichEditorRegistry(), @@ -261,15 +278,16 @@ export function RichTextEditor({ if (!richEditorDocumentsEqual(currentValue, normalizedValue)) { editor.commands.setContent(normalizedValue.doc, { emitUpdate: false }) } - queueManagedPreviewHydration(editor, previewLoadRef) - }, [editor, value]) + queueManagedPreviewHydration(editor, previewLoadRef, previewRetryTimerRef, previewCache) + }, [editor, previewCache, value]) useEffect(() => { if (!editor || editor.isDestroyed || !editorId) return const insertImage: RichEditorImageInserter = (attachmentId, filename, previewSrc) => { - if (editor.isDestroyed) return + if (editor.isDestroyed) return false const source = `${managedAttachmentProtocol}${attachmentId}` - editor + if (previewSrc) previewCache.seed(attachmentId, previewSrc) + const inserted = editor .chain() .focus(undefined, { scrollIntoView: false }) .insertContent({ @@ -282,13 +300,14 @@ export function RichTextEditor({ }) .run() - if (previewSrc) { - queueManagedPreviewHydration(editor, previewLoadRef) + if (inserted && previewSrc) { + queueManagedPreviewHydration(editor, previewLoadRef, previewRetryTimerRef, previewCache) } + return inserted } return registerRichEditor(editorId, { editor, insertImage, readOnly }) - }, [editor, editorId, readOnly]) + }, [editor, editorId, previewCache, readOnly]) if (!editor) { return
@@ -373,12 +392,32 @@ function editorAttributes({ return attributes } -function queueManagedPreviewHydration(editor: Editor, previewLoadRef: { current: number }) { +function queueManagedPreviewHydration( + editor: Editor, + previewLoadRef: { current: number }, + previewRetryTimerRef: { current: number | null }, + previewCache: ManagedAttachmentPreviewCache, +) { if (editor.isDestroyed) return const loadId = previewLoadRef.current + 1 previewLoadRef.current = loadId + if (previewRetryTimerRef.current !== null) { + window.clearTimeout(previewRetryTimerRef.current) + previewRetryTimerRef.current = null + } window.queueMicrotask(() => { if (editor.isDestroyed || previewLoadRef.current !== loadId) return - void hydrateManagedAttachmentPreviews(editor.view.dom, () => !editor.isDestroyed && previewLoadRef.current === loadId) + void hydrateManagedAttachmentPreviews( + editor.view.dom, + () => !editor.isDestroyed && previewLoadRef.current === loadId, + previewCache, + ).then((retryAfterMs) => { + if (retryAfterMs === null || editor.isDestroyed || previewLoadRef.current !== loadId) return + previewRetryTimerRef.current = window.setTimeout(() => { + previewRetryTimerRef.current = null + if (editor.isDestroyed || previewLoadRef.current !== loadId) return + queueManagedPreviewHydration(editor, previewLoadRef, previewRetryTimerRef, previewCache) + }, retryAfterMs) + }) }) } diff --git a/frontend/src/editor/editorHtml.test.ts b/frontend/src/editor/editorHtml.test.ts index eeabfb6..0937e53 100644 --- a/frontend/src/editor/editorHtml.test.ts +++ b/frontend/src/editor/editorHtml.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('../tauri', () => ({ getAttachmentPreviewDataUrl: vi.fn(), @@ -7,14 +7,20 @@ vi.mock('../tauri', () => ({ MANAGED_ATTACHMENT_PROTOCOL: 'qa-scribe-attachment://', })) +import { getAttachmentPreviewDataUrl } from '../tauri' import { + createManagedAttachmentPreviewCache, emptyEditorHtml, + hydrateManagedAttachmentPreviews, isSafeEditorImageSource, isSafeEditorLinkUrl, managedAttachmentImageHtml, + managedAttachmentProtocol, normalizeEditorHtml, } from './editorHtml' +const getAttachmentPreviewDataUrlMock = vi.mocked(getAttachmentPreviewDataUrl) + describe('editorHtml', () => { it('normalizes blank and TipTap-empty documents to a true blank value', () => { const blankInputs = ['', ' ', '
', '

', '


', '

 

', '<p><br></p>', ''] @@ -178,3 +184,204 @@ describe('editorHtml', () => { expect(isSafeEditorImageSource('file:///tmp/secret.png')).toBe(false) }) }) + +describe('managed attachment preview hydration', () => { + beforeEach(() => { + getAttachmentPreviewDataUrlMock.mockReset() + }) + + afterEach(() => { + document.body.replaceChildren() + }) + + it('loads duplicate images once and reuses the preview across text-only editor updates', async () => { + getAttachmentPreviewDataUrlMock.mockResolvedValue('data:image/png;base64,AAAA') + const editor = mountEditor(` +

Before typing

+ ${managedAttachmentImageHtml('attachment-1', 'first.png')} + ${managedAttachmentImageHtml('attachment-1', 'duplicate.png')} + `) + const cache = createManagedAttachmentPreviewCache() + + await hydrateManagedAttachmentPreviews(editor, () => true, cache) + + expect(getAttachmentPreviewDataUrlMock).toHaveBeenCalledTimes(1) + expect(getAttachmentPreviewDataUrlMock).toHaveBeenCalledWith('attachment-1') + expect(Array.from(editor.querySelectorAll('img')).map((image) => image.getAttribute('src'))).toEqual([ + 'data:image/png;base64,AAAA', + 'data:image/png;base64,AAAA', + ]) + + editor.querySelector('p')?.append(' and after typing') + await hydrateManagedAttachmentPreviews(editor, () => true, cache) + + expect(getAttachmentPreviewDataUrlMock).toHaveBeenCalledTimes(1) + }) + + it('deduplicates reads that are already in flight', async () => { + const preview = deferred() + getAttachmentPreviewDataUrlMock.mockReturnValue(preview.promise) + const editor = mountEditor(managedAttachmentImageHtml('attachment-1', 'evidence.png')) + const cache = createManagedAttachmentPreviewCache() + + const firstHydration = hydrateManagedAttachmentPreviews(editor, () => true, cache) + const secondHydration = hydrateManagedAttachmentPreviews(editor, () => true, cache) + await Promise.resolve() + + expect(getAttachmentPreviewDataUrlMock).toHaveBeenCalledTimes(1) + + preview.resolve('data:image/png;base64,AAAA') + await Promise.all([firstHydration, secondHydration]) + + expect(editor.querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,AAAA') + }) + + it('rejects a stale response after an attachment is replaced', async () => { + const firstPreview = deferred() + const replacementPreview = deferred() + getAttachmentPreviewDataUrlMock.mockImplementation((attachmentId) => + attachmentId === 'attachment-1' ? firstPreview.promise : replacementPreview.promise, + ) + const editor = mountEditor(managedAttachmentImageHtml('attachment-1', 'first.png')) + const image = editor.querySelector('img') + if (!image) throw new Error('managed image missing') + const cache = createManagedAttachmentPreviewCache() + + const firstHydration = hydrateManagedAttachmentPreviews(editor, () => true, cache) + await Promise.resolve() + image.setAttribute('data-attachment-id', 'attachment-2') + image.setAttribute('src', `${managedAttachmentProtocol}attachment-2`) + const replacementHydration = hydrateManagedAttachmentPreviews(editor, () => true, cache) + await Promise.resolve() + + replacementPreview.resolve('data:image/png;base64,BBBB') + await replacementHydration + firstPreview.resolve('data:image/png;base64,AAAA') + await firstHydration + + expect(getAttachmentPreviewDataUrlMock.mock.calls.map(([attachmentId]) => attachmentId)).toEqual(['attachment-1', 'attachment-2']) + expect(image.getAttribute('data-attachment-id')).toBe('attachment-2') + expect(image.getAttribute('src')).toBe('data:image/png;base64,BBBB') + }) + + it('drops removed identities so re-adding one starts a fresh read', async () => { + const removedPreview = deferred() + getAttachmentPreviewDataUrlMock.mockReturnValueOnce(removedPreview.promise).mockResolvedValueOnce('data:image/png;base64,BBBB') + const editor = mountEditor(managedAttachmentImageHtml('attachment-1', 'first.png')) + const image = editor.querySelector('img') + if (!image) throw new Error('managed image missing') + const cache = createManagedAttachmentPreviewCache() + + const removedHydration = hydrateManagedAttachmentPreviews(editor, () => true, cache) + await Promise.resolve() + image.remove() + await hydrateManagedAttachmentPreviews(editor, () => true, cache) + removedPreview.resolve('data:image/png;base64,AAAA') + await removedHydration + + expect(image.getAttribute('src')).toBe(`${managedAttachmentProtocol}attachment-1`) + + editor.append(image) + await hydrateManagedAttachmentPreviews(editor, () => true, cache) + + expect(getAttachmentPreviewDataUrlMock).toHaveBeenCalledTimes(2) + expect(image.getAttribute('src')).toBe('data:image/png;base64,BBBB') + }) + + it('waits for backoff before retrying a failed read and caches success', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + getAttachmentPreviewDataUrlMock.mockRejectedValueOnce(new Error('temporarily unavailable')).mockResolvedValueOnce('data:image/png;base64,AAAA') + const editor = mountEditor(managedAttachmentImageHtml('attachment-1', '')) + const cache = createManagedAttachmentPreviewCache() + + try { + expect(await hydrateManagedAttachmentPreviews(editor, () => true, cache)).toBe(250) + expect(editor.querySelector('img')?.getAttribute('alt')).toBe('Attached image') + + expect(await hydrateManagedAttachmentPreviews(editor, () => true, cache)).toBe(250) + expect(getAttachmentPreviewDataUrlMock).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(250) + expect(await hydrateManagedAttachmentPreviews(editor, () => true, cache)).toBeNull() + await hydrateManagedAttachmentPreviews(editor, () => true, cache) + + expect(getAttachmentPreviewDataUrlMock).toHaveBeenCalledTimes(2) + expect(editor.querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,AAAA') + } finally { + vi.useRealTimers() + } + }) + + it('bounds repeated failures to three reads for an unchanged identity', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + getAttachmentPreviewDataUrlMock.mockRejectedValue(new Error('unavailable')) + const editor = mountEditor(managedAttachmentImageHtml('attachment-1', 'evidence.png')) + const cache = createManagedAttachmentPreviewCache() + + try { + await hydrateManagedAttachmentPreviews(editor, () => true, cache) + await hydrateManagedAttachmentPreviews(editor, () => true, cache) + expect(getAttachmentPreviewDataUrlMock).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(250) + await hydrateManagedAttachmentPreviews(editor, () => true, cache) + await hydrateManagedAttachmentPreviews(editor, () => true, cache) + expect(getAttachmentPreviewDataUrlMock).toHaveBeenCalledTimes(2) + + vi.advanceTimersByTime(1_000) + await hydrateManagedAttachmentPreviews(editor, () => true, cache) + await hydrateManagedAttachmentPreviews(editor, () => true, cache) + + expect(getAttachmentPreviewDataUrlMock).toHaveBeenCalledTimes(3) + } finally { + vi.useRealTimers() + } + }) + + it('reports a retry without waiting for a slower sibling preview', async () => { + const failedPreview = deferred() + const slowPreview = deferred() + getAttachmentPreviewDataUrlMock.mockImplementation((attachmentId) => ( + attachmentId === 'attachment-1' ? failedPreview.promise : slowPreview.promise + )) + const editor = mountEditor(` + ${managedAttachmentImageHtml('attachment-1', 'failed.png')} + ${managedAttachmentImageHtml('attachment-2', 'slow.png')} + `) + const cache = createManagedAttachmentPreviewCache() + let retryAfterMs: number | null | undefined + const hydration = hydrateManagedAttachmentPreviews(editor, () => true, cache) + void hydration.then((value) => { retryAfterMs = value }) + await vi.waitFor(() => expect(getAttachmentPreviewDataUrlMock).toHaveBeenCalledTimes(2)) + + failedPreview.reject(new Error('temporarily unavailable')) + await vi.waitFor(() => expect(retryAfterMs).toBe(250)) + expect(editor.querySelector('img[data-attachment-id="attachment-2"]')?.getAttribute('src')).toBe( + `${managedAttachmentProtocol}attachment-2`, + ) + + slowPreview.resolve('data:image/png;base64,BBBB') + await vi.waitFor(() => { + expect(editor.querySelector('img[data-attachment-id="attachment-2"]')?.getAttribute('src')).toBe('data:image/png;base64,BBBB') + }) + }) +}) + +function mountEditor(html: string): HTMLElement { + const editor = document.createElement('div') + editor.innerHTML = html + document.body.append(editor) + return editor +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + return { promise, reject, resolve } +} diff --git a/frontend/src/editor/editorHtml.ts b/frontend/src/editor/editorHtml.ts index 846f736..99d0ee7 100644 --- a/frontend/src/editor/editorHtml.ts +++ b/frontend/src/editor/editorHtml.ts @@ -22,26 +22,134 @@ export function containsInlineImageData(value: string): boolean { return /]*\bsrc=["']data:image\//i.test(value) } -export async function hydrateManagedAttachmentPreviews(editor: HTMLElement, shouldApply: () => boolean) { +type ManagedAttachmentPreviewLoadResult = + | { status: 'resolved'; preview: string | null } + | { status: 'failed'; retryAfterMs: number | null } + | { status: 'stale' } + +type ManagedAttachmentPreviewCacheEntry = { + attempts: number + status: 'loading' | 'resolved' | 'failed' + preview?: string | null + promise?: Promise + retryAt?: number +} + +export type ManagedAttachmentPreviewCache = { + clear: () => void + load: (attachmentId: string) => Promise + retain: (attachmentIds: ReadonlySet) => void + seed: (attachmentId: string, preview: string) => void +} + +const managedAttachmentPreviewRetryDelays = [250, 1_000] as const +const maxManagedAttachmentPreviewAttempts = managedAttachmentPreviewRetryDelays.length + 1 + +export function createManagedAttachmentPreviewCache( + loadPreview: (attachmentId: string) => Promise = getAttachmentPreviewDataUrl, +): ManagedAttachmentPreviewCache { + const entries = new Map() + + function startLoad(attachmentId: string, attempts: number): Promise { + const entry: ManagedAttachmentPreviewCacheEntry = { attempts, status: 'loading' } + const promise = Promise.resolve() + .then(() => loadPreview(attachmentId)) + .then( + (preview): ManagedAttachmentPreviewLoadResult => { + if (entries.get(attachmentId) !== entry) return { status: 'stale' } + entry.status = 'resolved' + entry.preview = preview + return { status: 'resolved', preview } + }, + (): ManagedAttachmentPreviewLoadResult => { + if (entries.get(attachmentId) !== entry) return { status: 'stale' } + entry.status = 'failed' + const retryAfterMs = managedAttachmentPreviewRetryDelays[attempts - 1] ?? null + entry.retryAt = retryAfterMs === null ? undefined : Date.now() + retryAfterMs + return { status: 'failed', retryAfterMs } + }, + ) + + entry.promise = promise + entries.set(attachmentId, entry) + return promise + } + + return { + clear() { + entries.clear() + }, + load(attachmentId) { + const entry = entries.get(attachmentId) + if (!entry) return startLoad(attachmentId, 1) + if (entry.status === 'loading') return entry.promise ?? Promise.resolve({ status: 'failed', retryAfterMs: null }) + if (entry.status === 'resolved') return Promise.resolve({ status: 'resolved', preview: entry.preview ?? null }) + if (entry.attempts >= maxManagedAttachmentPreviewAttempts) return Promise.resolve({ status: 'failed', retryAfterMs: null }) + const retryAfterMs = Math.max(0, (entry.retryAt ?? 0) - Date.now()) + if (retryAfterMs > 0) return Promise.resolve({ status: 'failed', retryAfterMs }) + return startLoad(attachmentId, entry.attempts + 1) + }, + retain(attachmentIds) { + entries.forEach((_entry, attachmentId) => { + if (!attachmentIds.has(attachmentId)) entries.delete(attachmentId) + }) + }, + seed(attachmentId, preview) { + entries.set(attachmentId, { attempts: 0, status: 'resolved', preview }) + }, + } +} + +export async function hydrateManagedAttachmentPreviews( + editor: HTMLElement, + shouldApply: () => boolean, + cache: ManagedAttachmentPreviewCache, +): Promise { const images = Array.from(editor.querySelectorAll('img[data-attachment-id], img[src^="qa-scribe-attachment://"]')) - await Promise.all( - images.map(async (image) => { - const attachmentId = managedAttachmentIdFromImage(image) - if (!attachmentId) return - - image.setAttribute('data-attachment-id', attachmentId) - try { - const preview = await getAttachmentPreviewDataUrl(attachmentId) - if (preview && shouldApply() && image.isConnected) { - image.src = preview - } - } catch { - if (shouldApply() && image.isConnected) { - image.alt = image.alt || 'Attached image' - } + const imagesByAttachmentId = new Map() + + images.forEach((image) => { + const attachmentId = managedAttachmentIdFromImage(image) + if (!attachmentId) return + image.setAttribute('data-attachment-id', attachmentId) + const matchingImages = imagesByAttachmentId.get(attachmentId) ?? [] + matchingImages.push(image) + imagesByAttachmentId.set(attachmentId, matchingImages) + }) + + cache.retain(new Set(imagesByAttachmentId.keys())) + const loads = Array.from(imagesByAttachmentId, async ([attachmentId, matchingImages]) => { + const result = await cache.load(attachmentId) + if (result.status === 'stale' || !shouldApply()) return null + + matchingImages.forEach((image) => { + if (!image.isConnected || managedAttachmentIdFromImage(image) !== attachmentId) return + if (result.status === 'resolved') { + if (result.preview && image.getAttribute('src') !== result.preview) image.src = result.preview + } else { + image.alt = image.alt || 'Attached image' } - }), - ) + }) + + return result.status === 'failed' ? result.retryAfterMs : null + }) + + if (loads.length === 0) return null + return new Promise((resolve) => { + let pending = loads.length + let resolved = false + loads.forEach((load) => { + void load.then((retryAfterMs) => { + pending -= 1 + if (!resolved && retryAfterMs !== null) { + resolved = true + resolve(retryAfterMs) + } else if (!resolved && pending === 0) { + resolve(null) + } + }) + }) + }) } export function inlineImageFilename(image: HTMLImageElement, index: number, dataUrl: string): string { diff --git a/frontend/src/editor/richEditorRegistry.ts b/frontend/src/editor/richEditorRegistry.ts index c092347..9b5e760 100644 --- a/frontend/src/editor/richEditorRegistry.ts +++ b/frontend/src/editor/richEditorRegistry.ts @@ -1,9 +1,10 @@ import { useEffect, useState } from 'react' import type { Editor } from '@tiptap/react' -export type RichEditorImageInserter = (attachmentId: string, filename: string, previewSrc?: string) => void +export type RichEditorImageInserter = (attachmentId: string, filename: string, previewSrc?: string) => boolean export type RichEditorImageUpload = { + editorId: string file: File insertImage: RichEditorImageInserter } @@ -60,3 +61,7 @@ export function richEditorImageInserterForElement(element: HTMLElement): RichEdi if (!editor?.id) return null return editorRegistry.get(editor.id)?.insertImage ?? null } + +export function richEditorImageInserterForId(editorId: string): RichEditorImageInserter | null { + return editorRegistry.get(editorId)?.insertImage ?? null +} diff --git a/frontend/src/hooks/useSettingsController.test.ts b/frontend/src/hooks/useSettingsController.test.ts new file mode 100644 index 0000000..b270498 --- /dev/null +++ b/frontend/src/hooks/useSettingsController.test.ts @@ -0,0 +1,311 @@ +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createOpenGenerationPreflight } from '../app/generationPreflightAction' +import { readCachedProviderStatus } from '../settings/providerStatusCache' +import { providerModelDescriptorFixture, providerStatusFixture, settingsFixture } from '../test/fixtures' +import type { ProviderStatus } from '../tauri' +import { useSettingsController } from './useSettingsController' + +const tauriMock = vi.hoisted(() => ({ + getProviderStatus: vi.fn(), + refreshProviderStatus: vi.fn(), + updateSettings: vi.fn(), +})) + +vi.mock('../tauri', () => tauriMock) + +describe('useSettingsController provider observation ordering', () => { + beforeEach(() => { + vi.clearAllMocks() + ensureTestLocalStorage() + window.localStorage.clear() + window.matchMedia = vi.fn().mockReturnValue({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }) + tauriMock.updateSettings.mockResolvedValue(settingsFixture()) + }) + + afterEach(cleanup) + + it('does not let the shallow startup response replace a newer deep observation', async () => { + const fast = deferred() + const deep = deferred() + const fastStatus = providerObservation('fast-startup', 'unchecked', 'idle') + const deepStatus = providerObservation('deep-startup', 'detected', 'fresh') + tauriMock.getProviderStatus.mockReturnValueOnce(fast.promise) + tauriMock.refreshProviderStatus.mockReturnValueOnce(deep.promise) + const { result } = renderController() + + let fastRequest!: Promise + let deepRequest!: Promise + act(() => { + fastRequest = result.current.loadProviderStatus() + deepRequest = result.current.discoverProviderDefaults() + }) + + await act(async () => { + deep.resolve(deepStatus) + await deepRequest + }) + expect(result.current.providerStatus).toBe(deepStatus) + + await act(async () => { + fast.resolve(fastStatus) + await fastRequest + }) + + expect(result.current.providerStatus).toBe(deepStatus) + expect(result.current.providerDiscoveryState).toBe('ready') + expect(readCachedProviderStatus()?.providers[0].defaultSnapshot.model.value).toBe('deep-startup') + }) + + it('keeps a newer manual observation when automatic discovery completes last', async () => { + const automatic = deferred() + const manual = deferred() + const automaticStatus = providerObservation('automatic', 'detected', 'fresh') + const manualStatus = providerObservation('manual', 'detected', 'fresh') + tauriMock.refreshProviderStatus + .mockReturnValueOnce(automatic.promise) + .mockReturnValueOnce(manual.promise) + const { result } = renderController() + + let automaticRequest!: Promise + let manualRequest!: Promise + act(() => { + automaticRequest = result.current.discoverProviderDefaults() + manualRequest = result.current.refreshProviderStatus() + }) + + await act(async () => { + manual.resolve(manualStatus) + await manualRequest + }) + await act(async () => { + automatic.resolve(automaticStatus) + await automaticRequest + }) + + expect(result.current.providerStatus).toBe(manualStatus) + expect(result.current.providerDiscoveryState).toBe('ready') + expect(readCachedProviderStatus()?.providers[0].defaultSnapshot.model.value).toBe('manual') + }) + + it('ignores an older automatic failure after a newer manual observation succeeds', async () => { + const automatic = deferred() + const manualStatus = providerObservation('manual-after-failure', 'detected', 'fresh') + tauriMock.refreshProviderStatus + .mockReturnValueOnce(automatic.promise) + .mockResolvedValueOnce(manualStatus) + const { result } = renderController() + + let automaticRequest!: Promise + act(() => { + automaticRequest = result.current.discoverProviderDefaults() + }) + await act(async () => { + await result.current.refreshProviderStatus() + }) + await act(async () => { + automatic.reject(new Error('late automatic failure')) + await automaticRequest + }) + + expect(result.current.providerStatus).toBe(manualStatus) + expect(result.current.providerDiscoveryState).toBe('ready') + }) + + it('uses invocation order for repeated deep refreshes even when promises resolve in reverse', async () => { + const first = deferred() + const second = deferred() + const firstStatus = providerObservation('first-refresh', 'detected', 'fresh') + const secondStatus = providerObservation('second-refresh', 'detected', 'fresh') + tauriMock.refreshProviderStatus + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + const { result } = renderController() + + let firstRequest!: Promise + let secondRequest!: Promise + act(() => { + firstRequest = result.current.refreshProviderStatus() + secondRequest = result.current.refreshProviderStatus() + }) + + await act(async () => { + second.resolve(secondStatus) + await secondRequest + }) + await act(async () => { + first.resolve(firstStatus) + await firstRequest + }) + + expect(result.current.providerStatus).toBe(secondStatus) + expect(readCachedProviderStatus()?.providers[0].defaultSnapshot.model.value).toBe('second-refresh') + }) + + it('keeps a successful superseded refresh as the last-good fallback when the leader rejects', async () => { + const first = deferred() + const second = deferred() + const firstStatus = providerObservation('fallback-refresh', 'detected', 'fresh') + tauriMock.refreshProviderStatus + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + const { result } = renderController() + + let firstRequest!: Promise + let secondRequest!: Promise + act(() => { + firstRequest = result.current.refreshProviderStatus() + secondRequest = result.current.refreshProviderStatus() + }) + await act(async () => { + first.resolve(firstStatus) + await firstRequest + }) + + expect(result.current.providerStatus).toBe(firstStatus) + expect(result.current.providerDiscoveryState).toBe('refreshing') + + let rejected = false + await act(async () => { + second.reject(new Error('newest refresh failed')) + try { + await secondRequest + } catch { + rejected = true + } + }) + + expect(rejected).toBe(true) + expect(result.current.providerStatus).toBe(firstStatus) + expect(result.current.providerDiscoveryState).toBe('stale') + expect(readCachedProviderStatus()?.providers[0].defaultSnapshot.model.value).toBe('fallback-refresh') + }) + + it('orders a generation-preflight refresh with automatic discovery', async () => { + const automatic = deferred() + const preflight = deferred() + const automaticStatus = providerObservation('automatic-preflight-race', 'detected', 'fresh') + const preflightStatus = providerObservation('generation-preflight', 'detected', 'fresh') + tauriMock.refreshProviderStatus + .mockReturnValueOnce(automatic.promise) + .mockReturnValueOnce(preflight.promise) + const { result } = renderController() + const setBusyAction = vi.fn() + const setPendingGenerationAction = vi.fn() + const openGenerationPreflight = createOpenGenerationPreflight( + null, + result.current.refreshProviderStatus, + setBusyAction, + setPendingGenerationAction, + ) + + let automaticRequest!: Promise + let preflightRequest!: Promise + act(() => { + automaticRequest = result.current.discoverProviderDefaults() + preflightRequest = openGenerationPreflight('summary') + }) + + await act(async () => { + preflight.resolve(preflightStatus) + await preflightRequest + }) + await act(async () => { + automatic.resolve(automaticStatus) + await automaticRequest + }) + + expect(result.current.providerStatus).toBe(preflightStatus) + expect(setBusyAction).toHaveBeenNthCalledWith(1, 'refresh-providers') + expect(setBusyAction).toHaveBeenLastCalledWith(null) + expect(setPendingGenerationAction).toHaveBeenCalledWith('summary') + }) + + it('retains independent last-good catalog and default snapshots when the leading refresh rejects', async () => { + const lastGood = providerObservation('last-good', 'detected', 'stale') + lastGood.providers[0].catalogSnapshot.models = [ + providerModelDescriptorFixture({ id: 'retained-account-model', label: 'Retained account model' }), + ] + tauriMock.refreshProviderStatus + .mockResolvedValueOnce(lastGood) + .mockRejectedValueOnce(new Error('bridge unavailable')) + const { result } = renderController() + + await act(async () => { + await result.current.refreshProviderStatus() + }) + await act(async () => { + await result.current.discoverProviderDefaults() + }) + + expect(result.current.providerStatus).toBe(lastGood) + expect(result.current.providerStatus?.providers[0].defaultSnapshot).toMatchObject({ + state: 'detected', + model: { value: 'last-good' }, + }) + expect(result.current.providerStatus?.providers[0].catalogSnapshot).toMatchObject({ + state: 'stale', + models: [{ id: 'retained-account-model' }], + }) + expect(result.current.providerDiscoveryState).toBe('stale') + }) +}) + +function renderController() { + return renderHook(() => useSettingsController({ setError: vi.fn(), setNotice: vi.fn() })) +} + +function providerObservation( + marker: string, + defaultState: ProviderStatus['providers'][number]['defaultSnapshot']['state'], + catalogState: ProviderStatus['providers'][number]['catalogSnapshot']['state'], +): ProviderStatus { + const status = providerStatusFixture() + const model = providerModelDescriptorFixture({ id: marker, label: marker }) + status.providers[0] = { + ...status.providers[0], + reason: marker, + models: [model], + defaultSnapshot: { + ...status.providers[0].defaultSnapshot, + state: defaultState, + model: { ...status.providers[0].defaultSnapshot.model, value: marker }, + checkedAt: defaultState === 'unchecked' ? null : `2026-07-20T10:00:00.${marker.length.toString().padStart(3, '0')}Z`, + }, + catalogSnapshot: { + ...status.providers[0].catalogSnapshot, + state: catalogState, + models: [model], + checkedAt: catalogState === 'idle' ? null : `2026-07-20T10:00:00.${marker.length.toString().padStart(3, '0')}Z`, + }, + } + return status +} + +function deferred() { + let resolve: (value: T) => void = () => {} + let reject: (reason?: unknown) => void = () => {} + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve + reject = nextReject + }) + return { promise, reject, resolve } +} + +function ensureTestLocalStorage() { + if (typeof window.localStorage.clear === 'function') return + const storage = new Map() + const localStorage = { + get length() { return storage.size }, + clear() { storage.clear() }, + getItem(key: string) { return storage.get(key) ?? null }, + key(index: number) { return Array.from(storage.keys())[index] ?? null }, + removeItem(key: string) { storage.delete(key) }, + setItem(key: string, value: string) { storage.set(key, value) }, + } satisfies Storage + Object.defineProperty(window, 'localStorage', { configurable: true, value: localStorage }) +} diff --git a/frontend/src/hooks/useSettingsController.ts b/frontend/src/hooks/useSettingsController.ts index c92e959..b15ceff 100644 --- a/frontend/src/hooks/useSettingsController.ts +++ b/frontend/src/hooks/useSettingsController.ts @@ -26,6 +26,12 @@ export function useSettingsController({ }) { const [initialCachedProviderStatus] = useState(() => readCachedProviderStatus()) const cachedProviderStatusRef = useRef(initialCachedProviderStatus) + const providerStatusRef = useRef(initialCachedProviderStatus) + const providerObservationRef = useRef({ + nextSequence: 0, + accepted: null, + leading: null, + }) const [settings, setSettings] = useState(null) const [providerStatus, setProviderStatus] = useState(initialCachedProviderStatus) const [settingsDraft, setSettingsDraft] = useState(null) @@ -69,48 +75,83 @@ export function useSettingsController({ } }, []) - function loadSettings(nextSettings: AppSettings, nextProviderStatus: ProviderStatus | null = null) { + function loadSettings(nextSettings: AppSettings) { settingsDraftVersionRef.current += 1 setSettings(nextSettings) setSettingsDraft(nextSettings) - if (nextProviderStatus) { - cachedProviderStatusRef.current = null - setProviderStatus(nextProviderStatus) - } } async function refreshProviderStatus() { - setProviderDiscoveryState('refreshing') + const observation = beginProviderObservation('deep', 'refreshing') try { const status = await refreshProviderStatusCommand() - writeCachedProviderStatus(status) - setProviderStatus(status) - setProviderDiscoveryState(discoveryUiState(status)) + acceptProviderObservation(observation, status, true) } catch (cause) { - setProviderDiscoveryState((previous) => previous === 'ready' || previous === 'stale' ? 'stale' : 'error') - throw cause + if (failProviderObservation(observation)) throw cause } } async function loadProviderStatus() { - setProviderDiscoveryState('checking') - const fastStatus = await getProviderStatus() - const status = mergeFastProviderStatus(fastStatus, cachedProviderStatusRef.current) - cachedProviderStatusRef.current = null - setProviderStatus(status) - setProviderDiscoveryState(discoveryUiState(status)) + const observation = beginProviderObservation('fast', 'checking') + try { + const fastStatus = await getProviderStatus() + const status = mergeFastProviderStatus(fastStatus, cachedProviderStatusRef.current) + acceptProviderObservation(observation, status, false) + } catch (cause) { + if (failProviderObservation(observation)) throw cause + } } async function discoverProviderDefaults() { - setProviderDiscoveryState('checking') + const observation = beginProviderObservation('deep', 'checking') try { const deepStatus = await refreshProviderStatusCommand() - writeCachedProviderStatus(deepStatus) - setProviderStatus(deepStatus) - setProviderDiscoveryState(discoveryUiState(deepStatus)) + acceptProviderObservation(observation, deepStatus, true) } catch { - setProviderDiscoveryState('error') + failProviderObservation(observation) + } + } + + function beginProviderObservation( + depth: ProviderObservationDepth, + pendingState: ProviderDiscoveryUiState, + ): ProviderObservation { + const coordinator = providerObservationRef.current + const observation = { sequence: ++coordinator.nextSequence, depth } + const boundary = coordinator.leading ?? coordinator.accepted + if (!boundary || compareProviderObservations(observation, boundary) > 0) { + coordinator.leading = observation + setProviderDiscoveryState(pendingState) } + return observation + } + + function acceptProviderObservation( + observation: ProviderObservation, + status: ProviderStatus, + cache: boolean, + ): boolean { + const coordinator = providerObservationRef.current + if (coordinator.accepted && compareProviderObservations(observation, coordinator.accepted) < 0) return false + const isLeading = !coordinator.leading + || compareProviderObservations(observation, coordinator.leading) >= 0 + + coordinator.accepted = observation + if (isLeading) coordinator.leading = observation + cachedProviderStatusRef.current = null + providerStatusRef.current = status + if (cache) writeCachedProviderStatus(status) + setProviderStatus(status) + if (isLeading) setProviderDiscoveryState(discoveryUiState(status)) + return true + } + + function failProviderObservation(observation: ProviderObservation): boolean { + const coordinator = providerObservationRef.current + if (!sameProviderObservation(observation, coordinator.leading)) return false + coordinator.leading = coordinator.accepted + setProviderDiscoveryState(providerStatusRef.current ? 'stale' : 'error') + return true } async function persistSettings(nextSettings: AppSettings, draftVersion: number): Promise { @@ -185,6 +226,36 @@ export function useSettingsController({ } } +type ProviderObservationDepth = 'fast' | 'deep' + +type ProviderObservation = { + sequence: number + depth: ProviderObservationDepth +} + +type ProviderObservationCoordinator = { + nextSequence: number + accepted: ProviderObservation | null + leading: ProviderObservation | null +} + +const PROVIDER_OBSERVATION_DEPTH: Record = { + fast: 0, + deep: 1, +} + +function compareProviderObservations(left: ProviderObservation, right: ProviderObservation): number { + return PROVIDER_OBSERVATION_DEPTH[left.depth] - PROVIDER_OBSERVATION_DEPTH[right.depth] + || left.sequence - right.sequence +} + +function sameProviderObservation( + left: ProviderObservation, + right: ProviderObservation | null, +): boolean { + return Boolean(right && left.sequence === right.sequence && left.depth === right.depth) +} + function discoveryUiState(status: ProviderStatus): ProviderDiscoveryUiState { const states = status.providers.map((provider) => provider.defaultSnapshot.state) if (states.some((state) => state === 'stale')) return 'stale' diff --git a/frontend/src/tauriCommands.ts b/frontend/src/tauriCommands.ts index fbeb4ce..63a00e6 100644 --- a/frontend/src/tauriCommands.ts +++ b/frontend/src/tauriCommands.ts @@ -111,6 +111,10 @@ export function importClipboardScreenshot(input: { return commands.importClipboardScreenshot(input.sessionId, input.entryId, input.filename, input.dataUrl) } +export function deleteAttachment(attachmentId: string): Promise { + return commands.deleteAttachment(attachmentId) +} + export function readClipboardImageDataUrl(): Promise { return commands.readClipboardImageDataUrl() } diff --git a/frontend/src/views/FindingsView.tsx b/frontend/src/views/FindingsView.tsx index dd24c33..dde01a7 100644 --- a/frontend/src/views/FindingsView.tsx +++ b/frontend/src/views/FindingsView.tsx @@ -82,7 +82,7 @@ export function FindingsView({ onPrefillFromNote: () => Promise onSaveFinding: (finding: Finding) => Promise onDiscardFinding: (finding: Finding) => void - onUploadImage: (input: RichEditorImageUpload) => void | Promise + onUploadImage: (input: RichEditorImageUpload, recordId: string) => void | Promise }) { return ( ({ onPrefillFromNote: () => Promise onDiscardRecord: (record: T) => void onSaveRecord: (record: T) => Promise - onUploadImage: (input: RichEditorImageUpload) => void | Promise + onUploadImage: (input: RichEditorImageUpload, recordId: string) => void | Promise }) { const [editingRecordIds, setEditingRecordIds] = useState>({}) const [editingOriginals, setEditingOriginals] = useState>({}) @@ -275,7 +275,7 @@ export function RecordCollectionView({ previewHeader={renderPreviewHeader(record)} onTitleChange={(title) => updateLocalRecord(record.id, { title })} onBodyChange={(patch) => updateLocalRecord(record.id, patch)} - onUploadImage={onUploadImage} + onUploadImage={(input) => onUploadImage(input, record.id)} actions={ ) : null} -
- - {busyAction === 'save-title' || busyAction === 'save-body' ? 'Saving...' : 'Autosaved'} +
+ {sessionSaveState === 'invalid' || sessionSaveState === 'unsaved' + ? + : } + {saveStatusLabel}
- + {noteWordCount} words
diff --git a/frontend/src/views/TestwareView.tsx b/frontend/src/views/TestwareView.tsx index d7dc5e8..68f02e8 100644 --- a/frontend/src/views/TestwareView.tsx +++ b/frontend/src/views/TestwareView.tsx @@ -77,7 +77,7 @@ export function TestwareView({ onPrefillFromNote: () => Promise onSaveDraft: (draft: Draft) => Promise onDiscardDraft: (draft: Draft) => void - onUploadImage: (input: RichEditorImageUpload) => void | Promise + onUploadImage: (input: RichEditorImageUpload, recordId: string) => void | Promise }) { return ( { expect(button.className).toContain('success') }) + it('shows required-title validation instead of claiming the Session is autosaved', () => { + render( + Body

')} + noteIsReady + noteScreenshotCount={0} + sessionTitle=" " + sessionTitleValidationError="Session title is required." + sessionSaveState="invalid" + noteWordCount={1} + notice={null} + error={null} + pendingAiActions={{}} + selectedProvider="codex_cli" + selectedModel="default" + activeProvider={providerStatus.providers[0]} + onAiAction={async () => undefined} + onUndoLatestGeneration={async () => undefined} + onCopyNote={async () => undefined} + onCopyNoteScreenshot={async () => undefined} + onDeleteSession={() => undefined} + onOpenSession={async () => undefined} + onSetNoteBody={() => undefined} + onSetSessionTitle={() => undefined} + onUploadImage={() => undefined} + />, + ) + + expect(screen.getByRole('textbox', { name: 'Session title' })).toHaveAttribute('aria-invalid', 'true') + expect(screen.getByRole('alert')).toHaveTextContent('Session title is required.') + expect(screen.getByText('Title required').closest('[role="status"]')).toHaveTextContent('Title required') + expect(screen.queryByText('Autosaved')).not.toBeInTheDocument() + }) + it('makes the effective model prominent and exposes a clear configuration action', async () => { const user = userEvent.setup() const onConfigureAi = vi.fn() diff --git a/package.json b/package.json index 3c3068b..c048e3e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "qa-scribe", - "version": "0.7.20", + "version": "0.7.21", "description": "Local-first desktop testing notepad for turning testing sessions into structured testware.", "desktopName": "qa-scribe.desktop", "homepage": "https://github.com/ddv1982/qa-scribe", diff --git a/scripts/bump-version.mjs b/scripts/bump-version.mjs index 81f3419..69270c4 100644 --- a/scripts/bump-version.mjs +++ b/scripts/bump-version.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node // Single write-path for bumping the qa-scribe release version. // -// Updates, atomically (all files staged in memory, then written together) -// and idempotently (safe to re-run with the same target version): +// Updates recoverably (all outputs and rollback copies are staged before any +// same-directory replacement) and idempotently (safe to re-run with the same +// target version): // - package.json `version` // - frontend/package.json `version` // - Cargo.toml `[workspace.package]` version @@ -23,7 +24,7 @@ // Usage: // node scripts/bump-version.mjs [--dry-run] -import { readFile, writeFile } from 'node:fs/promises' +import { readFile } from 'node:fs/promises' import { spawnSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import { @@ -33,6 +34,7 @@ import { readWorkspaceCargoVersion, validateStableSemver } from './command-utils.mjs' +import { applyPlanTransaction, recoverInterruptedVersionTransaction } from './version-transaction.mjs' const PACKAGE_JSON_PATH = 'package.json' const FRONTEND_PACKAGE_JSON_PATH = 'frontend/package.json' @@ -43,6 +45,9 @@ const CHANGELOG_PATH = 'CHANGELOG.md' const CARGO_LOCK_CRATES = QA_SCRIBE_CARGO_LOCK_PACKAGES async function main() { + if (await recoverInterruptedVersionTransaction()) { + console.log('Recovered an interrupted version bump before preflight.') + } const args = process.argv.slice(2) const dryRun = args.includes('--dry-run') const positional = args.filter(arg => !arg.startsWith('--')) @@ -74,9 +79,7 @@ async function main() { return } - for (const change of plan) { - await writeFile(change.path, change.nextContent, 'utf8') - } + await applyPlanTransaction(plan) console.log(`\nBumped version ${currentVersion} -> ${newVersion}.`) console.log(`Reminder: fill in the CHANGELOG.md entry for ## v${newVersion} - ${today} with real release notes before tagging.`) @@ -159,6 +162,7 @@ function buildPlan(files, { newVersion, today, metainfoPath }) { plan.push({ path: PACKAGE_JSON_PATH, description: `version -> ${newVersion}`, + previousContent: files.packageJsonRaw ?? `${JSON.stringify(files.packageJson, null, 2)}\n`, nextContent: `${JSON.stringify(nextPackageJson, null, 2)}\n` }) @@ -166,18 +170,21 @@ function buildPlan(files, { newVersion, today, metainfoPath }) { plan.push({ path: FRONTEND_PACKAGE_JSON_PATH, description: `version -> ${newVersion}`, + previousContent: files.frontendPackageJsonRaw ?? `${JSON.stringify(files.frontendPackageJson, null, 2)}\n`, nextContent: `${JSON.stringify(nextFrontendPackageJson, null, 2)}\n` }) plan.push({ path: CARGO_TOML_PATH, description: `[workspace.package] version -> ${newVersion}`, + previousContent: files.cargoToml, nextContent: bumpCargoTomlVersion(files.cargoToml, newVersion) }) plan.push({ path: CARGO_LOCK_PATH, description: `${CARGO_LOCK_CRATES.join(', ')} version -> ${newVersion}`, + previousContent: files.cargoLock, nextContent: bumpCargoLockVersions(files.cargoLock, newVersion) }) @@ -185,6 +192,7 @@ function buildPlan(files, { newVersion, today, metainfoPath }) { plan.push({ path: TAURI_CONF_PATH, description: `version -> ${newVersion}`, + previousContent: files.tauriConfRaw ?? `${JSON.stringify(files.tauriConf, null, 2)}\n`, nextContent: `${JSON.stringify(nextTauriConf, null, 2)}\n` }) @@ -192,12 +200,14 @@ function buildPlan(files, { newVersion, today, metainfoPath }) { plan.push({ path: CHANGELOG_PATH, description: `insert ## ${changelogTag} - ${today} scaffold`, + previousContent: files.changelog, nextContent: insertChangelogScaffold(files.changelog, changelogTag, today) }) plan.push({ path: metainfoPath, description: `insert `, + previousContent: files.metainfo, nextContent: insertMetainfoRelease(files.metainfo, newVersion, today) }) @@ -232,7 +242,8 @@ function bumpCargoLockVersions(cargoLock, newVersion) { function insertChangelogScaffold(changelog, tag, today) { const heading = `## ${tag} - ${today}` - if (changelog.includes(`\n${heading}\n`) || changelog.startsWith(`${heading}\n`)) { + const existingVersionHeading = new RegExp(`(?:^|\\n)## ${escapeRegExpLocal(tag)} - \\d{4}-\\d{2}-\\d{2}(?:\\n|$)`) + if (existingVersionHeading.test(changelog)) { return changelog } @@ -279,11 +290,13 @@ if (isMainModule) { } export { + applyPlanTransaction, buildPlan, bumpCargoLockVersions, bumpCargoTomlVersion, cargoLockCrateVersions, insertChangelogScaffold, insertMetainfoRelease, - preflightConsistencyCheck + preflightConsistencyCheck, + recoverInterruptedVersionTransaction } diff --git a/scripts/bump-version.test.mjs b/scripts/bump-version.test.mjs index 617645f..dfe7bae 100644 --- a/scripts/bump-version.test.mjs +++ b/scripts/bump-version.test.mjs @@ -97,8 +97,7 @@ function sampleFiles(version) { } test('preflightConsistencyCheck returns the shared current version', () => { - const current = preflightConsistencyCheck(sampleFiles('0.4.24')) - assert.equal(current, '0.4.24') + assert.equal(preflightConsistencyCheck(sampleFiles('0.4.24')), '0.4.24') }) test('preflightConsistencyCheck throws loudly when files disagree', () => { @@ -120,8 +119,7 @@ test('preflightConsistencyCheck throws when a version is missing entirely', () = }) test('cargoLockCrateVersions reads all three qa-scribe crate versions', () => { - const versions = cargoLockCrateVersions(SAMPLE_CARGO_LOCK) - assert.deepEqual(versions, { + assert.deepEqual(cargoLockCrateVersions(SAMPLE_CARGO_LOCK), { 'qa-scribe-app': '0.4.24', 'qa-scribe-core': '0.4.24', 'qa-scribe-tauri': '0.4.24' @@ -135,14 +133,11 @@ test('bumpCargoTomlVersion updates only the workspace.package version', () => { }) test('bumpCargoTomlVersion is idempotent when already at the target version', () => { - const once = bumpCargoTomlVersion(SAMPLE_CARGO_TOML, '0.4.24') - assert.equal(once, SAMPLE_CARGO_TOML) + assert.equal(bumpCargoTomlVersion(SAMPLE_CARGO_TOML, '0.4.24'), SAMPLE_CARGO_TOML) }) test('bumpCargoLockVersions updates all three qa-scribe crate entries', () => { - const next = bumpCargoLockVersions(SAMPLE_CARGO_LOCK, '0.5.0') - const versions = cargoLockCrateVersions(next) - assert.deepEqual(versions, { + assert.deepEqual(cargoLockCrateVersions(bumpCargoLockVersions(SAMPLE_CARGO_LOCK, '0.5.0')), { 'qa-scribe-app': '0.5.0', 'qa-scribe-core': '0.5.0', 'qa-scribe-tauri': '0.5.0' @@ -156,41 +151,38 @@ test('bumpCargoLockVersions throws if a crate entry is missing', () => { test('insertChangelogScaffold inserts a new heading right after the title', () => { const next = insertChangelogScaffold(SAMPLE_CHANGELOG, 'v0.5.0', '2026-07-03') - const lines = next.split('\n') - assert.equal(lines[0], '# Changelog') - assert.equal(lines[2], '## v0.5.0 - 2026-07-03') + assert.equal(next.split('\n')[2], '## v0.5.0 - 2026-07-03') assert.match(next, /## v0\.5\.0 - 2026-07-03[\s\S]*## v0\.4\.24 - 2026-07-02/) }) -test('insertChangelogScaffold is idempotent for an existing heading', () => { - const once = insertChangelogScaffold(SAMPLE_CHANGELOG, 'v0.4.24', '2026-07-02') - assert.equal(once, SAMPLE_CHANGELOG) +test('insertChangelogScaffold is idempotent for an existing version even on a later date', () => { + assert.equal(insertChangelogScaffold(SAMPLE_CHANGELOG, 'v0.4.24', '2026-07-20'), SAMPLE_CHANGELOG) }) -test('insertMetainfoRelease inserts a new release entry at the top of ', () => { +test('insertMetainfoRelease inserts a new release entry at the top of releases', () => { const next = insertMetainfoRelease(SAMPLE_METAINFO, '0.5.0', '2026-07-03') - const releasesIndex = next.indexOf('') - const newEntryIndex = next.indexOf('') - const oldEntryIndex = next.indexOf('') - assert.ok(releasesIndex < newEntryIndex) - assert.ok(newEntryIndex < oldEntryIndex) + assert.ok(next.indexOf('') < next.indexOf(' { - const once = insertMetainfoRelease(SAMPLE_METAINFO, '0.4.24', '2026-07-02') - assert.equal(once, SAMPLE_METAINFO) + assert.equal(insertMetainfoRelease(SAMPLE_METAINFO, '0.4.24', '2026-07-02'), SAMPLE_METAINFO) }) -test('insertMetainfoRelease throws when is missing', () => { - const malformed = SAMPLE_METAINFO.replace('', '') - assert.throws(() => insertMetainfoRelease(malformed, '0.5.0', '2026-07-03'), /releases/) +test('insertMetainfoRelease throws when releases are missing', () => { + assert.throws( + () => insertMetainfoRelease(SAMPLE_METAINFO.replace('', ''), '0.5.0', '2026-07-03'), + /releases/ + ) }) test('buildPlan produces a change for every tracked file with the new version applied', () => { - const files = sampleFiles('0.4.24') - const plan = buildPlan(files, { newVersion: '0.5.0', today: '2026-07-03', metainfoPath: 'build/linux/io.github.ddv1982.qa-scribe.metainfo.xml' }) - const paths = plan.map(change => change.path).sort() - assert.deepEqual(paths, [ + const plan = buildPlan(sampleFiles('0.4.24'), { + newVersion: '0.5.0', + today: '2026-07-03', + metainfoPath: 'build/linux/io.github.ddv1982.qa-scribe.metainfo.xml' + }) + assert.deepEqual(plan.map(change => change.path).sort(), [ 'CHANGELOG.md', 'Cargo.lock', 'Cargo.toml', @@ -199,14 +191,20 @@ test('buildPlan produces a change for every tracked file with the new version ap 'package.json', 'src-tauri/tauri.conf.json' ]) + assert.equal(JSON.parse(plan.find(change => change.path === 'package.json').nextContent).version, '0.5.0') +}) - const packageJsonChange = plan.find(change => change.path === 'package.json') - assert.equal(JSON.parse(packageJsonChange.nextContent).version, '0.5.0') - - const cargoLockChange = plan.find(change => change.path === 'Cargo.lock') - assert.deepEqual(cargoLockCrateVersions(cargoLockChange.nextContent), { - 'qa-scribe-app': '0.5.0', - 'qa-scribe-core': '0.5.0', - 'qa-scribe-tauri': '0.5.0' +test('buildPlan is content-idempotent when the target version is already current', () => { + const files = { + ...sampleFiles('0.4.24'), + packageJsonRaw: `${JSON.stringify(samplePackageJson('0.4.24'), null, 2)}\n`, + frontendPackageJsonRaw: `${JSON.stringify(sampleFrontendPackageJson('0.4.24'), null, 2)}\n`, + tauriConfRaw: `${JSON.stringify(sampleTauriConf('0.4.24'), null, 2)}\n` + } + const plan = buildPlan(files, { + newVersion: '0.4.24', + today: '2026-07-20', + metainfoPath: 'build/linux/io.github.ddv1982.qa-scribe.metainfo.xml' }) + assert.ok(plan.every(change => change.nextContent === change.previousContent)) }) diff --git a/scripts/check-code-size.mjs b/scripts/check-code-size.mjs index a545353..402f858 100644 --- a/scripts/check-code-size.mjs +++ b/scripts/check-code-size.mjs @@ -4,7 +4,19 @@ import { readFileSync, readdirSync, statSync } from 'node:fs' import { extname, join, relative, resolve, sep } from 'node:path' import { pathToFileURL } from 'node:url' -const SOURCE_EXTENSIONS = new Set(['.cjs', '.js', '.jsx', '.mjs', '.py', '.rs', '.sh', '.ts', '.tsx']) +const SOURCE_EXTENSIONS = new Set([ + '.cjs', + '.js', + '.jsx', + '.mjs', + '.py', + '.rs', + '.sh', + '.ts', + '.tsx', + '.yaml', + '.yml', +]) const EXCLUDED_DIRECTORIES = new Set(['.git', 'build', 'dist', 'node_modules', 'out', 'target']) const GENERATED_FILES = new Set(['frontend/src/bindings.ts']) const REQUIRED_REVIEW_FIELDS = ['reason', 'splitCost', 'reviewTrigger'] diff --git a/scripts/check-code-size.test.mjs b/scripts/check-code-size.test.mjs index f8d048a..5831c24 100644 --- a/scripts/check-code-size.test.mjs +++ b/scripts/check-code-size.test.mjs @@ -28,6 +28,26 @@ test('inspectCodeSize reports watch files and fails unapproved oversized files', } }) +test('collects maintained YAML workflows under the same size policy', () => { + const root = mkdtempSync(join(tmpdir(), 'qa-scribe-code-size-')) + try { + mkdirSync(join(root, '.github'), { recursive: true }) + mkdirSync(join(root, '.github/workflows'), { recursive: true }) + writeFileSync(join(root, '.github/workflows/ci.yml'), 'step:\n'.repeat(300)) + writeFileSync(join(root, '.github/workflows/release.yaml'), 'step:\n'.repeat(501)) + + const result = inspectCodeSize(root, policy, '2026-07-20') + assert.deepEqual(result.watched, [{ path: '.github/workflows/ci.yml', lineCount: 300 }]) + assert.deepEqual(result.failures, [{ + path: '.github/workflows/release.yaml', + lineCount: 501, + reason: 'no approved exception', + }]) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + test('inspectCodeSize accepts a current exception and rejects it after review date', () => { const root = mkdtempSync(join(tmpdir(), 'qa-scribe-code-size-')) try { diff --git a/scripts/check-e2e-isolation.mjs b/scripts/check-e2e-isolation.mjs index ee0ded4..253e6dd 100755 --- a/scripts/check-e2e-isolation.mjs +++ b/scripts/check-e2e-isolation.mjs @@ -34,6 +34,12 @@ export function inspectE2eIsolation(root, { sourceOnly = false } = {}) { if (!/PATH:\s*\[fixtureBin, process\.env\.PATH\][\s\S]*QA_SCRIBE_E2E_PROVIDER_PATH:\s*fixtureBin/.test(runner)) { failures.push('the deterministic provider fixture must lead E2E Fast and Deep PATH resolution') } + if (!/--outDir',\s*frontendDist,\s*'--emptyOutDir/.test(runner) || !/tauriConfig\.build\.frontendDist\s*=\s*frontendDist/.test(runner)) { + failures.push('the E2E frontend and Tauri config must use the isolated temporary output directory') + } + if (/restoreProductionFrontend/.test(runner)) { + failures.push('the E2E runner must not rely on rebuilding frontend/dist after tests') + } const parsedProductionConfig = JSON.parse(productionConfig) if (parsedProductionConfig.app?.withGlobalTauri !== false) failures.push('production withGlobalTauri must remain false') diff --git a/scripts/code-size-policy.json b/scripts/code-size-policy.json index 0112cfe..758f4af 100644 --- a/scripts/code-size-policy.json +++ b/scripts/code-size-policy.json @@ -1,7 +1,15 @@ { "maxLines": 500, "watchLines": 300, - "exceptions": [], + "exceptions": [ + { + "path": ".github/workflows/release.yml", + "reason": "The release workflow is one dependency-ordered packaging, signing, validation, and publication pipeline whose platform jobs share artifact contracts.", + "splitCost": "Extracting jobs without a proven responsibility seam would move coupling into reusable-workflow inputs and obscure permissions, conditions, and artifact provenance.", + "reviewDate": "2026-10-20", + "reviewTrigger": "Review when a packaging platform gains an independent release cadence or the workflow adds another separately invocable responsibility." + } + ], "excludedFiles": [ { "path": "scripts/build_apt_repository.py", diff --git a/scripts/install-cargo-audit.mjs b/scripts/install-cargo-audit.mjs new file mode 100644 index 0000000..2fabd4d --- /dev/null +++ b/scripts/install-cargo-audit.mjs @@ -0,0 +1,33 @@ +import { spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +const STABLE_SEMVER = /^\d+\.\d+\.\d+$/ + +export function readCargoAuditVersion( + toolVersions = JSON.parse(readFileSync(new URL('./tool-versions.json', import.meta.url), 'utf8')), +) { + const version = toolVersions.cargoAudit + if (typeof version !== 'string' || !STABLE_SEMVER.test(version)) { + throw new Error('scripts/tool-versions.json must contain a stable cargoAudit version') + } + return version +} + +export function installCargoAudit(version, spawn = spawnSync) { + const result = spawn( + 'cargo', + ['install', 'cargo-audit', '--locked', '--version', `=${version}`], + { stdio: 'inherit' }, + ) + + if (result.error) throw result.error + if (result.status !== 0) throw new Error(`cargo install cargo-audit exited with status ${result.status}`) +} + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1] +if (isMain) { + const version = readCargoAuditVersion() + console.log(`Installing pinned cargo-audit ${version}`) + installCargoAudit(version) +} diff --git a/scripts/install-cargo-audit.test.mjs b/scripts/install-cargo-audit.test.mjs new file mode 100644 index 0000000..06bb360 --- /dev/null +++ b/scripts/install-cargo-audit.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { installCargoAudit, readCargoAuditVersion } from './install-cargo-audit.mjs' + +test('readCargoAuditVersion reads and validates the explicit tool pin', () => { + assert.equal(readCargoAuditVersion({ cargoAudit: '0.22.2' }), '0.22.2') + assert.throws(() => readCargoAuditVersion({}), /stable cargoAudit version/) + assert.throws(() => readCargoAuditVersion({ cargoAudit: '0.23.0-beta.1' }), /stable cargoAudit version/) +}) + +test('the repository pins the reviewed cargo-audit version', () => { + assert.equal(readCargoAuditVersion(), '0.22.2') +}) + +test('installCargoAudit invokes cargo install with the exact tool pin', () => { + let invocation + installCargoAudit('0.22.2', (command, args, options) => { + invocation = { command, args, options } + return { status: 0 } + }) + + assert.deepEqual(invocation, { + command: 'cargo', + args: ['install', 'cargo-audit', '--locked', '--version', '=0.22.2'], + options: { stdio: 'inherit' }, + }) +}) + +test('installCargoAudit rejects a failed cargo install process', () => { + assert.throws(() => installCargoAudit('0.22.2', () => ({ status: 7 })), /status 7/) +}) + +test('the shared CI and release gate installs cargo-audit through the pinned installer', () => { + const action = readFileSync('.github/actions/validate-build/action.yml', 'utf8') + const workflows = [ + readFileSync('.github/workflows/ci.yml', 'utf8'), + readFileSync('.github/workflows/release.yml', 'utf8'), + ] + + assert.match(action, /run: node scripts\/install-cargo-audit\.mjs/) + for (const workflow of workflows) { + assert.equal((workflow.match(/uses:\s*\.\/\.github\/actions\/validate-build/g) ?? []).length, 1) + } + for (const source of [action, ...workflows]) { + assert.doesNotMatch(source, /cargo\s+install\s+cargo-audit\b/) + } +}) diff --git a/scripts/run-e2e.mjs b/scripts/run-e2e.mjs index b0beb13..735f33c 100755 --- a/scripts/run-e2e.mjs +++ b/scripts/run-e2e.mjs @@ -4,110 +4,202 @@ import { spawnSync } from 'node:child_process' import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { delimiter, dirname, join, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') -const artifacts = resolve(process.env.QA_SCRIBE_E2E_ARTIFACTS ?? join(root, 'artifacts', 'e2e')) const fixtureBin = join(root, 'e2e', 'fixtures', 'bin') -const ownsTemporaryRoot = !process.env.QA_SCRIBE_E2E_TEMP_ROOT -const temporaryRoot = resolve(process.env.QA_SCRIBE_E2E_TEMP_ROOT ?? mkdtempSync(join(tmpdir(), 'qa-scribe-e2e-'))) -const skipBuild = process.env.QA_SCRIBE_E2E_SKIP_BUILD === '1' -const startedAt = Date.now() -let result = { status: 1, signal: null } -let productionFrontendRestored = skipBuild - -if (process.env.QA_SCRIBE_E2E_PRESERVE_ARTIFACTS !== '1') rmSync(artifacts, { recursive: true, force: true }) -mkdirSync(artifacts, { recursive: true }) -chmodSync(join(fixtureBin, 'codex'), 0o755) - -const isolatedEnvironment = { - ...process.env, - HOME: join(temporaryRoot, 'home'), - XDG_CONFIG_HOME: join(temporaryRoot, 'config'), - XDG_DATA_HOME: join(temporaryRoot, 'data'), - XDG_CACHE_HOME: join(temporaryRoot, 'cache'), - TMPDIR: join(temporaryRoot, 'tmp'), - // Toolchain caches are build inputs, not application/provider state. Keep - // them shared so isolation does not redownload Rust for every E2E run. - CARGO_HOME: process.env.CARGO_HOME ?? join(homedir(), '.cargo'), - RUSTUP_HOME: process.env.RUSTUP_HOME ?? join(homedir(), '.rustup'), - // Fast provider readiness reads PATH directly. Put the fixture first so a - // clean CI runner and a developer machine with real CLIs behave identically. - PATH: [fixtureBin, process.env.PATH].filter(Boolean).join(delimiter), - QA_SCRIBE_E2E_PROVIDER_PATH: fixtureBin, - QA_SCRIBE_E2E_ARTIFACTS: artifacts, - QA_SCRIBE_E2E_APP_DATA_DIR: join(temporaryRoot, 'app-data'), - QA_SCRIBE_E2E_BINARY: join(root, 'target', 'debug', process.platform === 'win32' ? 'qa-scribe-tauri.exe' : 'qa-scribe-tauri'), - TAURI_CONFIG: JSON.stringify(JSON.parse(readFileSync(join(root, 'e2e', 'tauri.e2e.conf.json'), 'utf8'))), -} +const criticalSpec = 'critical-workflows.e2e.mjs' -for (const directory of ['home', 'config', 'data', 'cache', 'tmp', 'app-data']) { - mkdirSync(join(temporaryRoot, directory), { recursive: true }) -} +export const criticalScenarios = Object.freeze([ + 'session-lifecycle', + 'manual-testware', + 'clipboard', + 'generation-cancellation', + 'summary-recovery', +]) + +export function createWdioInvocationPlan(environment, temporaryRoot, artifacts) { + const requestedSpec = environment.QA_SCRIBE_E2E_SPEC?.trim() + const requestedScenario = environment.QA_SCRIBE_E2E_SCENARIO?.trim() -function run(command, args, environment = isolatedEnvironment) { - const completed = spawnSync(command, args, { - cwd: root, - env: environment, - stdio: 'inherit', - shell: false, - }) - if (completed.status !== 0) { - throw new Error(`${command} ${args.join(' ')} failed with exit code ${completed.status ?? 'unknown'}`) + if (requestedSpec && requestedSpec !== criticalSpec) { + if (requestedScenario) throw new Error('QA_SCRIBE_E2E_SCENARIO can only select a critical workflow') + return [ + { + id: requestedSpec, + environment: { + QA_SCRIBE_E2E_SPEC: requestedSpec, + QA_SCRIBE_E2E_APP_DATA_DIR: join(temporaryRoot, 'app-data'), + QA_SCRIBE_E2E_ARTIFACTS: artifacts, + QA_SCRIBE_E2E_FIXTURE_DIR: join(temporaryRoot, 'fixtures'), + }, + }, + ] } - return completed + + const scenarios = requestedScenario ? [requestedScenario] : criticalScenarios + for (const scenario of scenarios) { + if (!criticalScenarios.includes(scenario)) throw new Error(`Unknown critical E2E scenario: ${scenario}`) + } + + return scenarios.map((scenario) => ({ + id: scenario, + environment: { + QA_SCRIBE_E2E_SPEC: criticalSpec, + QA_SCRIBE_E2E_SCENARIO: scenario, + QA_SCRIBE_E2E_APP_DATA_DIR: join(temporaryRoot, 'app-data', scenario), + QA_SCRIBE_E2E_ARTIFACTS: join(artifacts, 'critical-workflows', scenario), + QA_SCRIBE_E2E_FIXTURE_DIR: join(temporaryRoot, 'fixtures', scenario), + }, + })) } -function restoreProductionFrontend() { - const productionEnvironment = { ...process.env } - delete productionEnvironment.VITE_QA_SCRIBE_E2E - run('bun', ['run', '--cwd', 'frontend', 'build'], productionEnvironment) - productionFrontendRestored = true +export function executeWdioInvocations(invocations, execute) { + const results = [] + for (const invocation of invocations) { + try { + const completed = execute(invocation) + results.push({ + id: invocation.id, + status: completed.status, + signal: completed.signal ?? null, + stdout: completed.stdout ?? '', + stderr: completed.stderr ?? '', + }) + } catch (error) { + results.push({ + id: invocation.id, + status: 1, + signal: null, + stdout: '', + stderr: `${error instanceof Error ? error.message : error}\n`, + }) + } + } + + const firstFailure = results.find((result) => result.status !== 0) + return { + status: firstFailure ? firstFailure.status ?? 1 : 0, + signal: firstFailure?.signal ?? null, + results, + } } -try { - if (!skipBuild) { - run('bun', ['run', '--cwd', 'frontend', 'build'], { ...isolatedEnvironment, VITE_QA_SCRIBE_E2E: '1' }) - run('cargo', ['build', '--package', 'qa-scribe-tauri', '--features', 'e2e']) - restoreProductionFrontend() +export function runE2e(environment = process.env, { spawnSyncImpl = spawnSync } = {}) { + const artifacts = resolve(environment.QA_SCRIBE_E2E_ARTIFACTS ?? join(root, 'artifacts', 'e2e')) + const ownsTemporaryRoot = !environment.QA_SCRIBE_E2E_TEMP_ROOT + const temporaryRoot = resolve(environment.QA_SCRIBE_E2E_TEMP_ROOT ?? mkdtempSync(join(tmpdir(), 'qa-scribe-e2e-'))) + const frontendDist = join(temporaryRoot, 'frontend-dist') + const skipBuild = environment.QA_SCRIBE_E2E_SKIP_BUILD === '1' + const startedAt = Date.now() + let result = { status: 1, signal: null, results: [] } + + if (environment.QA_SCRIBE_E2E_PRESERVE_ARTIFACTS !== '1') rmSync(artifacts, { recursive: true, force: true }) + mkdirSync(artifacts, { recursive: true }) + chmodSync(join(fixtureBin, 'codex'), 0o755) + + const tauriConfig = JSON.parse(readFileSync(join(root, 'e2e', 'tauri.e2e.conf.json'), 'utf8')) + tauriConfig.build.frontendDist = frontendDist + const isolatedEnvironment = { + ...environment, + HOME: join(temporaryRoot, 'home'), + XDG_CONFIG_HOME: join(temporaryRoot, 'config'), + XDG_DATA_HOME: join(temporaryRoot, 'data'), + XDG_CACHE_HOME: join(temporaryRoot, 'cache'), + TMPDIR: join(temporaryRoot, 'tmp'), + // Toolchain caches are build inputs, not application/provider state. Keep + // them shared so isolation does not redownload Rust for every E2E run. + CARGO_HOME: environment.CARGO_HOME ?? join(homedir(), '.cargo'), + RUSTUP_HOME: environment.RUSTUP_HOME ?? join(homedir(), '.rustup'), + // Fast provider readiness reads PATH directly. Put the fixture first so a + // clean CI runner and a developer machine with real CLIs behave identically. + PATH: [fixtureBin, process.env.PATH].filter(Boolean).join(delimiter), + QA_SCRIBE_E2E_PROVIDER_PATH: fixtureBin, + QA_SCRIBE_E2E_BINARY: join(root, 'target', 'debug', process.platform === 'win32' ? 'qa-scribe-tauri.exe' : 'qa-scribe-tauri'), + TAURI_CONFIG: JSON.stringify(tauriConfig), } - const wdio = join(root, 'frontend', 'node_modules', '.bin', process.platform === 'win32' ? 'wdio.cmd' : 'wdio') - result = spawnSync(wdio, ['run', join(root, 'e2e', 'wdio.conf.mjs')], { - cwd: root, - env: isolatedEnvironment, - encoding: 'utf8', - shell: process.platform === 'win32', - }) - process.stdout.write(result.stdout ?? '') - process.stderr.write(result.stderr ?? '') - writeFileSync(join(artifacts, 'wdio-run.log'), `${result.stdout ?? ''}${result.stderr ?? ''}`) -} catch (error) { - console.error(error instanceof Error ? error.message : error) -} finally { - if (!productionFrontendRestored) { - try { - restoreProductionFrontend() - } catch (error) { - console.error(`Failed to restore the production frontend: ${error instanceof Error ? error.message : error}`) + for (const directory of ['home', 'config', 'data', 'cache', 'tmp', 'app-data', 'fixtures']) { + mkdirSync(join(temporaryRoot, directory), { recursive: true }) + } + + function run(command, args, commandEnvironment = isolatedEnvironment) { + const completed = spawnSyncImpl(command, args, { + cwd: root, + env: commandEnvironment, + stdio: 'inherit', + shell: false, + }) + if (completed.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed with exit code ${completed.status ?? 'unknown'}`) } + return completed } - const metadata = { - completedAt: new Date().toISOString(), - durationMs: Date.now() - startedAt, - platform: process.platform, - architecture: process.arch, - runnerClass: process.env.QA_SCRIBE_STARTUP_RUNNER_CLASS || `${process.platform}-${process.arch}`, - runId: process.env.GITHUB_RUN_ID ?? null, - runAttempt: Number(process.env.GITHUB_RUN_ATTEMPT || 1), - status: result.status === 0 ? 'passed' : 'failed', - exitCode: result.status, - signal: result.signal, - isolatedDataRemoved: ownsTemporaryRoot, + try { + if (!skipBuild) { + run( + 'bun', + ['run', '--cwd', 'frontend', 'build', '--', '--outDir', frontendDist, '--emptyOutDir'], + { ...isolatedEnvironment, VITE_QA_SCRIBE_E2E: '1' }, + ) + run('cargo', ['build', '--package', 'qa-scribe-tauri', '--features', 'e2e']) + } + + const wdio = join(root, 'frontend', 'node_modules', '.bin', process.platform === 'win32' ? 'wdio.cmd' : 'wdio') + const invocations = createWdioInvocationPlan(environment, temporaryRoot, artifacts) + result = executeWdioInvocations(invocations, (invocation) => { + const invocationEnvironment = { ...isolatedEnvironment, ...invocation.environment } + if (invocation.environment.QA_SCRIBE_E2E_SCENARIO) { + rmSync(invocation.environment.QA_SCRIBE_E2E_APP_DATA_DIR, { recursive: true, force: true }) + rmSync(invocation.environment.QA_SCRIBE_E2E_FIXTURE_DIR, { recursive: true, force: true }) + } + mkdirSync(invocation.environment.QA_SCRIBE_E2E_APP_DATA_DIR, { recursive: true }) + mkdirSync(invocation.environment.QA_SCRIBE_E2E_ARTIFACTS, { recursive: true }) + mkdirSync(invocation.environment.QA_SCRIBE_E2E_FIXTURE_DIR, { recursive: true }) + const completed = spawnSyncImpl(wdio, ['run', join(root, 'e2e', 'wdio.conf.mjs')], { + cwd: root, + env: invocationEnvironment, + encoding: 'utf8', + shell: process.platform === 'win32', + }) + const stdout = completed.stdout ?? '' + const stderr = `${completed.stderr ?? ''}${completed.error ? `${completed.error.message}\n` : ''}` + process.stdout.write(stdout) + process.stderr.write(stderr) + writeFileSync(join(invocation.environment.QA_SCRIBE_E2E_ARTIFACTS, 'wdio-run.log'), `${stdout}${stderr}`) + return { ...completed, stdout, stderr } + }) + + const aggregateLog = result.results + .map((invocation) => `===== ${invocation.id} (exit ${invocation.status ?? 'unknown'}) =====\n${invocation.stdout}${invocation.stderr}`) + .join('\n') + writeFileSync(join(artifacts, 'wdio-run.log'), aggregateLog) + for (const failed of result.results.filter((invocation) => invocation.status !== 0)) { + console.error(`E2E invocation failed: ${failed.id} (exit ${failed.status ?? 'unknown'})`) + } + } catch (error) { + console.error(error instanceof Error ? error.message : error) + } finally { + const metadata = { + completedAt: new Date().toISOString(), + durationMs: Date.now() - startedAt, + platform: process.platform, + architecture: process.arch, + runnerClass: environment.QA_SCRIBE_STARTUP_RUNNER_CLASS || `${process.platform}-${process.arch}`, + runId: environment.GITHUB_RUN_ID ?? null, + runAttempt: Number(environment.GITHUB_RUN_ATTEMPT || 1), + status: result.status === 0 ? 'passed' : 'failed', + exitCode: result.status, + signal: result.signal, + invocations: result.results.map(({ id, status, signal }) => ({ id, status, signal })), + isolatedDataRemoved: ownsTemporaryRoot, + } + writeFileSync(join(artifacts, 'latest-run.json'), `${JSON.stringify(metadata, null, 2)}\n`) + if (ownsTemporaryRoot) rmSync(temporaryRoot, { recursive: true, force: true }) } - writeFileSync(join(artifacts, 'latest-run.json'), `${JSON.stringify(metadata, null, 2)}\n`) - if (ownsTemporaryRoot) rmSync(temporaryRoot, { recursive: true, force: true }) + + return result.status === 0 ? 0 : result.status ?? 1 } -process.exitCode = result.status === 0 ? 0 : result.status ?? 1 +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) process.exitCode = runE2e() diff --git a/scripts/run-e2e.test.mjs b/scripts/run-e2e.test.mjs new file mode 100644 index 0000000..89bcbfd --- /dev/null +++ b/scripts/run-e2e.test.mjs @@ -0,0 +1,189 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync, watch, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import test from 'node:test' +import { once } from 'node:events' + +import { createWdioInvocationPlan, criticalScenarios, executeWdioInvocations, runE2e } from './run-e2e.mjs' + +test('critical workflows use separate WDIO processes, app data, fixtures, and artifacts', () => { + const plan = createWdioInvocationPlan({}, '/tmp/e2e-root', '/tmp/e2e-artifacts') + + assert.deepEqual(plan.map(({ id }) => id), criticalScenarios) + for (const key of ['QA_SCRIBE_E2E_APP_DATA_DIR', 'QA_SCRIBE_E2E_ARTIFACTS', 'QA_SCRIBE_E2E_FIXTURE_DIR']) { + assert.equal(new Set(plan.map(({ environment }) => environment[key])).size, criticalScenarios.length) + } + assert.ok(plan.every(({ environment }) => environment.QA_SCRIBE_E2E_SPEC === 'critical-workflows.e2e.mjs')) +}) + +test('an explicit non-critical spec runs once with the supplied app-data root', () => { + const plan = createWdioInvocationPlan( + { QA_SCRIBE_E2E_SPEC: 'startup-performance.benchmark.mjs' }, + '/tmp/startup-root', + '/tmp/startup-artifacts', + ) + + assert.equal(plan.length, 1) + assert.equal(plan[0].environment.QA_SCRIBE_E2E_APP_DATA_DIR, join('/tmp/startup-root', 'app-data')) + assert.equal(plan[0].environment.QA_SCRIBE_E2E_ARTIFACTS, '/tmp/startup-artifacts') + assert.equal(plan[0].environment.QA_SCRIBE_E2E_SCENARIO, undefined) +}) + +test('a failed critical workflow does not prevent later workflow invocations', () => { + const invoked = [] + const plan = criticalScenarios.map((id) => ({ id })) + const result = executeWdioInvocations(plan, ({ id }) => { + invoked.push(id) + return { status: id === 'manual-testware' ? 9 : 0, signal: null } + }) + + assert.deepEqual(invoked, criticalScenarios) + assert.equal(result.status, 9) + assert.deepEqual(result.results.map(({ status }) => status), criticalScenarios.map((id) => id === 'manual-testware' ? 9 : 0)) +}) + +test('an interrupted E2E build never targets or rebuilds the production frontend', () => { + const temporaryRoot = mkdtempSync(join(tmpdir(), 'qa-scribe-runner-')) + const calls = [] + try { + const status = runE2e( + { + ...process.env, + QA_SCRIBE_E2E_TEMP_ROOT: join(temporaryRoot, 'isolated'), + QA_SCRIBE_E2E_ARTIFACTS: join(temporaryRoot, 'artifacts'), + VITE_QA_SCRIBE_E2E: 'inherited-value-must-be-removed', + }, + { + spawnSyncImpl(command, args, options) { + calls.push({ command, args, environment: options.env }) + return { status: command === 'cargo' ? 2 : 0, signal: null } + }, + }, + ) + + assert.equal(status, 1) + assert.deepEqual(calls.map(({ command }) => command), ['bun', 'cargo']) + assert.equal(calls[0].environment.VITE_QA_SCRIBE_E2E, '1') + assert.deepEqual(calls[0].args.slice(0, 4), ['run', '--cwd', 'frontend', 'build']) + const outputIndex = calls[0].args.indexOf('--outDir') + assert.notEqual(outputIndex, -1) + assert.equal(calls[0].args[outputIndex + 1], join(temporaryRoot, 'isolated', 'frontend-dist')) + assert.notEqual(calls[0].args[outputIndex + 1], resolve('frontend/dist')) + const tauriConfig = JSON.parse(calls[1].environment.TAURI_CONFIG) + assert.equal(tauriConfig.build.frontendDist, join(temporaryRoot, 'isolated', 'frontend-dist')) + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }) + } +}) + +for (const [label, wdioStatus] of [['successful', 0], ['failed', 4]]) { + test(`a ${label} E2E invocation uses only the isolated frontend build`, () => { + const temporaryRoot = mkdtempSync(join(tmpdir(), 'qa-scribe-runner-')) + const calls = [] + try { + const status = runE2e( + { + ...process.env, + QA_SCRIBE_E2E_TEMP_ROOT: join(temporaryRoot, 'isolated'), + QA_SCRIBE_E2E_ARTIFACTS: join(temporaryRoot, 'artifacts'), + QA_SCRIBE_E2E_SPEC: 'single-workflow.e2e.mjs', + }, + { + spawnSyncImpl(command, args, options) { + calls.push({ command, args, environment: options.env }) + return { status: command.includes('wdio') ? wdioStatus : 0, signal: null, stdout: '', stderr: '' } + }, + }, + ) + + assert.equal(status, wdioStatus) + assert.equal(calls.filter(({ command }) => command === 'bun').length, 1) + assert.equal(calls.filter(({ command }) => command === 'cargo').length, 1) + const frontendBuild = calls.find(({ command }) => command === 'bun') + const outputIndex = frontendBuild.args.indexOf('--outDir') + assert.equal(frontendBuild.args[outputIndex + 1], join(temporaryRoot, 'isolated', 'frontend-dist')) + assert.ok(calls.every(({ args }) => !args.includes(resolve('frontend/dist')))) + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }) + } + }) +} + +test('shared and standalone callers build production assets independently when needed', () => { + const action = readFileSync('.github/actions/run-built-app-e2e/action.yml', 'utf8') + const benchmark = readFileSync('scripts/run-startup-benchmark.mjs', 'utf8') + + assert.match(action, /bun run frontend:build && bun run e2e:isolation/) + assert.match(benchmark, /if \(!existsSync\(join\(root, 'frontend', 'dist', 'assets'\)\)\)/) + assert.match(benchmark, /run\('bun', \['run', 'frontend:build'\]\)/) +}) + +test('the Codex fixture completes only released invocations and leaves cancellation pending', { timeout: 5_000 }, async () => { + const controlDirectory = mkdtempSync(join(tmpdir(), 'qa-scribe-codex-fixture-')) + const fixture = resolve('e2e/fixtures/bin/codex') + const children = [] + try { + const first = startFixture(fixture, controlDirectory) + children.push(first.child) + first.child.stdin.end('Create test scenarios with test cases from the selected note only') + await waitForPath(join(controlDirectory, 'codex-exec-1.started')) + assert.doesNotMatch(first.output(), /item\.completed/) + writeFileSync(join(controlDirectory, 'codex-exec-1.release'), 'release\n') + const [firstCode] = await once(first.child, 'close') + assert.equal(firstCode, 0) + assert.match(first.output(), /Deterministic generated case/) + assert.match(first.output(), /item\.completed/) + + const second = startFixture(fixture, controlDirectory) + children.push(second.child) + second.child.stdin.end('Create test scenarios with test cases from the selected note only') + await waitForPath(join(controlDirectory, 'codex-exec-2.started')) + second.child.kill('SIGTERM') + await once(second.child, 'close') + assert.equal(existsSync(join(controlDirectory, 'codex-exec-2.release')), false) + assert.doesNotMatch(second.output(), /item\.completed/) + } finally { + for (const child of children) { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL') + } + rmSync(controlDirectory, { recursive: true, force: true }) + } +}) + +function startFixture(fixture, controlDirectory) { + let output = '' + const child = spawn(process.execPath, [fixture, 'exec', '--json'], { + env: { ...process.env, QA_SCRIBE_E2E_FIXTURE_DIR: controlDirectory }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + child.stdout.on('data', (chunk) => { + output += chunk + }) + child.stderr.on('data', (chunk) => { + output += chunk + }) + return { child, output: () => output } +} + +function waitForPath(path) { + if (existsSync(path)) return Promise.resolve() + return new Promise((resolvePromise, reject) => { + const watcher = watch(dirname(path), () => { + if (!existsSync(path)) return + clearTimeout(timeout) + watcher.close() + resolvePromise() + }) + const timeout = setTimeout(() => { + watcher.close() + reject(new Error(`Timed out waiting for ${path}`)) + }, 2_000) + if (existsSync(path)) { + clearTimeout(timeout) + watcher.close() + resolvePromise() + } + }) +} diff --git a/scripts/run-startup-benchmark.mjs b/scripts/run-startup-benchmark.mjs index 16cb3a7..fc1cc05 100644 --- a/scripts/run-startup-benchmark.mjs +++ b/scripts/run-startup-benchmark.mjs @@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process' import { gzipSync } from 'node:zlib' -import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -40,6 +40,9 @@ function run(command, args, environment = process.env) { try { run('cargo', ['run', '--quiet', '-p', 'qa-scribe-core', '--example', 'generate_startup_fixture', '--', database]) + if (!existsSync(join(root, 'frontend', 'dist', 'assets'))) { + run('bun', ['run', 'frontend:build']) + } for (let index = 0; index < sampleCount; index += 1) { const runKind = index === 0 ? 'cold-process' : 'warm-process' diff --git a/scripts/smoke-release-artifacts.mjs b/scripts/smoke-release-artifacts.mjs new file mode 100644 index 0000000..78b8b67 --- /dev/null +++ b/scripts/smoke-release-artifacts.mjs @@ -0,0 +1,451 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process' +import { + access, + constants as fsConstants, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + rm +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { readOption, readReleaseConstants } from './command-utils.mjs' + +const APP_PACKAGE_NAME = 'qa-scribe' +const APP_EXECUTABLE = '/usr/bin/qa-scribe' +const SETUP_PACKAGE_NAME = 'qa-scribe-repository-setup' +const SETUP_KEYRING_PATH = '/usr/share/keyrings/qa-scribe-archive-keyring.pgp' +const SETUP_SOURCES_PATH = '/etc/apt/sources.list.d/qa-scribe.sources' +const DEFAULT_LAUNCH_MILLISECONDS = 5_000 + +async function main(argv = process.argv.slice(2)) { + const linuxDebAppImageDirectory = readOption(argv, '--linux-deb-appimage') + const linuxRpmDirectory = readOption(argv, '--linux-rpm') + const aptSetupDeb = readOption(argv, '--apt-setup') + const expectedKeyring = readOption(argv, '--expected-keyring') + const macosDmgDirectory = readOption(argv, '--macos-dmg') + const selectedModes = [linuxDebAppImageDirectory, linuxRpmDirectory, aptSetupDeb, macosDmgDirectory].filter(Boolean) + + if (selectedModes.length !== 1) { + throw new Error( + 'Select exactly one smoke mode: --linux-deb-appimage , --linux-rpm , --apt-setup --expected-keyring , or --macos-dmg ' + ) + } + + if (linuxDebAppImageDirectory) { + await smokeLinuxDebAndAppImageArtifacts({ artifactDirectory: linuxDebAppImageDirectory }) + return + } + + if (linuxRpmDirectory) { + await smokeLinuxRpmArtifact({ artifactDirectory: linuxRpmDirectory }) + return + } + + if (aptSetupDeb) { + if (!expectedKeyring) { + throw new Error('--apt-setup requires --expected-keyring ') + } + await smokeAptSetupPackage({ setupDebPath: aptSetupDeb, expectedKeyringPath: expectedKeyring }) + return + } + + await smokeMacosDmg({ artifactDirectory: macosDmgDirectory }) +} + +async function smokeLinuxDebAndAppImageArtifacts({ + artifactDirectory, + commandRunner = runCommand, + launcher = assertLaunchStaysRunning, + launchMilliseconds = DEFAULT_LAUNCH_MILLISECONDS, + pathAbsenceChecker = assertPathsAbsent, + platform = process.platform +}) { + if (platform !== 'linux') { + throw new Error('Linux desktop package smoke must run on Linux') + } + + const directory = resolve(artifactDirectory) + const names = await readdir(directory) + const artifacts = selectLinuxDebAndAppImageArtifacts(names) + const paths = Object.fromEntries(Object.entries(artifacts).map(([kind, name]) => [kind, join(directory, name)])) + await pathAbsenceChecker([APP_EXECUTABLE]) + const temporaryRoot = await mkdtemp(join(tmpdir(), 'qa-scribe-package-smoke-')) + const environment = await isolatedLaunchEnvironment(temporaryRoot) + + try { + await withCleanup( + async () => { + await commandRunner('sudo', ['apt-get', 'install', '-y', '--no-install-recommends', paths.deb]) + await launcher('xvfb-run', ['-a', '--server-args=-screen 0 1280x800x24', APP_EXECUTABLE], { + env: environment, + milliseconds: launchMilliseconds, + label: 'installed deb application' + }) + }, + async () => { + await commandRunner('sudo', ['dpkg', '--purge', APP_PACKAGE_NAME]) + await pathAbsenceChecker([APP_EXECUTABLE]) + } + ) + + await access(paths.appImage, fsConstants.X_OK) + await launcher('xvfb-run', ['-a', '--server-args=-screen 0 1280x800x24', paths.appImage], { + env: { ...environment, APPIMAGE_EXTRACT_AND_RUN: '1' }, + milliseconds: launchMilliseconds, + label: 'AppImage application' + }) + } finally { + await rm(temporaryRoot, { recursive: true, force: true }) + } + + console.log(`Release artifact smoke passed for ${basename(paths.deb)} and ${basename(paths.appImage)}.`) +} + +async function smokeLinuxRpmArtifact({ + artifactDirectory, + commandRunner = runCommand, + launcher = assertLaunchStaysRunning, + launchMilliseconds = DEFAULT_LAUNCH_MILLISECONDS, + pathAbsenceChecker = assertPathsAbsent, + platform = process.platform +}) { + if (platform !== 'linux') { + throw new Error('Linux RPM smoke must run on Linux') + } + + const directory = resolve(artifactDirectory) + const rpmName = selectLinuxRpmArtifact((await readdir(directory))) + const rpmPath = join(directory, rpmName) + await pathAbsenceChecker([APP_EXECUTABLE]) + const temporaryRoot = await mkdtemp(join(tmpdir(), 'qa-scribe-rpm-smoke-')) + const environment = await isolatedLaunchEnvironment(temporaryRoot) + + try { + await withCleanup( + async () => { + await commandRunner('dnf', ['install', '-y', '--setopt=install_weak_deps=False', rpmPath]) + await commandRunner('dnf', ['install', '-y', 'xorg-x11-server-Xvfb', 'xorg-x11-xauth']) + await launcher('xvfb-run', ['-a', '--server-args=-screen 0 1280x800x24', APP_EXECUTABLE], { + env: environment, + milliseconds: launchMilliseconds, + label: 'installed rpm application' + }) + }, + async () => { + await commandRunner('dnf', ['remove', '-y', APP_PACKAGE_NAME]) + await pathAbsenceChecker([APP_EXECUTABLE]) + } + ) + } finally { + await rm(temporaryRoot, { recursive: true, force: true }) + } + + console.log(`RPM artifact smoke passed for ${rpmName}.`) +} + +async function smokeAptSetupPackage({ + setupDebPath, + expectedKeyringPath, + commandRunner = runCommand, + verification = verifyAptSetupInstallation, + pathAbsenceChecker = assertPathsAbsent, + platform = process.platform +}) { + if (platform !== 'linux') { + throw new Error('APT setup package smoke must run on Linux') + } + + const setupDeb = resolve(setupDebPath) + const keyring = resolve(expectedKeyringPath) + await access(setupDeb, fsConstants.R_OK) + await access(keyring, fsConstants.R_OK) + await pathAbsenceChecker([SETUP_KEYRING_PATH, SETUP_SOURCES_PATH]) + + await withCleanup( + async () => { + await commandRunner('sudo', ['apt-get', 'install', '-y', '--no-install-recommends', setupDeb]) + await verification({ + keyringPath: SETUP_KEYRING_PATH, + sourcesPath: SETUP_SOURCES_PATH, + expectedKeyringPath: keyring, + expectedRepositoryUrl: readReleaseConstants().pagesBaseUrl, + architectures: ['amd64'], + requireRootOwnership: true + }) + }, + async () => { + await commandRunner('sudo', ['dpkg', '--purge', SETUP_PACKAGE_NAME]) + await pathAbsenceChecker([SETUP_KEYRING_PATH, SETUP_SOURCES_PATH]) + } + ) + + console.log(`APT setup package smoke passed for ${basename(setupDeb)}.`) +} + +async function smokeMacosDmg({ + artifactDirectory, + commandRunner = runCommand, + launcher = assertLaunchStaysRunning, + launchMilliseconds = DEFAULT_LAUNCH_MILLISECONDS, + platform = process.platform +}) { + if (platform !== 'darwin') { + throw new Error('DMG smoke must run on macOS') + } + + const directory = resolve(artifactDirectory) + const names = await readdir(directory) + const dmgName = selectSingle(names, name => name.endsWith('.dmg'), 'DMG') + const dmgPath = join(directory, dmgName) + const temporaryRoot = await mkdtemp(join(tmpdir(), 'qa-scribe-dmg-smoke-')) + const mountPath = join(temporaryRoot, 'mounted') + const copyPath = join(temporaryRoot, 'copied') + await mkdir(mountPath) + await mkdir(copyPath) + let mounted = false + + await withCleanup( + async () => { + await commandRunner('hdiutil', ['attach', dmgPath, '-readonly', '-nobrowse', '-mountpoint', mountPath]) + mounted = true + + const mountedNames = await readdir(mountPath) + const appName = selectSingle(mountedNames, name => name.endsWith('.app'), 'application bundle in mounted DMG') + const mountedApp = join(mountPath, appName) + const copiedApp = join(copyPath, appName) + await commandRunner('ditto', [mountedApp, copiedApp]) + + const executable = join(copiedApp, 'Contents', 'MacOS', 'qa-scribe') + await access(executable, fsConstants.X_OK) + await launcher(executable, [], { + env: process.env, + milliseconds: launchMilliseconds, + label: 'application copied from DMG' + }) + }, + async () => { + await withCleanup( + async () => { + if (mounted) { + await commandRunner('hdiutil', ['detach', mountPath]) + } + }, + async () => rm(temporaryRoot, { recursive: true, force: true }) + ) + } + ) + + console.log(`DMG mount/copy/launch smoke passed for ${dmgName}.`) +} + +function selectLinuxDesktopArtifacts(names) { + return { + ...selectLinuxDebAndAppImageArtifacts(names), + rpm: selectLinuxRpmArtifact(names) + } +} + +function selectLinuxDebAndAppImageArtifacts(names) { + return { + deb: selectSingle(names, name => /^qa-scribe_[^/]+\.deb$/.test(name), 'desktop deb'), + appImage: selectSingle(names, name => name.endsWith('.AppImage'), 'AppImage') + } +} + +function selectLinuxRpmArtifact(names) { + return selectSingle(names, name => /^qa-scribe-[^/]+\.rpm$/.test(name), 'desktop rpm') +} + +function selectSingle(names, predicate, label) { + const matches = names.filter(predicate).sort() + if (matches.length !== 1) { + throw new Error(`Expected exactly one ${label} artifact, found ${matches.length}: ${matches.join(', ') || ''}`) + } + return matches[0] +} + +function expectedAptSources({ repositoryUrl, architectures }) { + return [ + 'Types: deb', + `URIs: ${repositoryUrl}`, + 'Suites: stable', + 'Components: main', + ...(architectures.length > 0 ? [`Architectures: ${architectures.join(' ')}`] : []), + `Signed-By: ${SETUP_KEYRING_PATH}`, + '' + ].join('\n') +} + +async function verifyAptSetupInstallation({ + keyringPath, + sourcesPath, + expectedKeyringPath, + expectedRepositoryUrl, + architectures, + requireRootOwnership +}) { + const [keyringStat, sourcesStat, installedKeyring, expectedKeyring, installedSources] = await Promise.all([ + lstat(keyringPath), + lstat(sourcesPath), + readFile(keyringPath), + readFile(expectedKeyringPath), + readFile(sourcesPath, 'utf8') + ]) + + for (const [label, fileStat] of [['keyring', keyringStat], ['sources', sourcesStat]]) { + if (!fileStat.isFile()) { + throw new Error(`Installed APT ${label} path is not a regular file`) + } + const mode = fileStat.mode & 0o777 + if (mode !== 0o644) { + throw new Error(`Installed APT ${label} mode is ${mode.toString(8)}, expected 644`) + } + if (requireRootOwnership && (fileStat.uid !== 0 || fileStat.gid !== 0)) { + throw new Error(`Installed APT ${label} must be owned by root:root, got ${fileStat.uid}:${fileStat.gid}`) + } + } + + if (!installedKeyring.equals(expectedKeyring)) { + throw new Error('Installed APT keyring content differs from the release keyring artifact') + } + + const expectedSources = expectedAptSources({ repositoryUrl: expectedRepositoryUrl, architectures }) + if (installedSources !== expectedSources) { + throw new Error(`Installed APT sources content differs from the expected Deb822 source:\n${installedSources}`) + } +} + +async function isolatedLaunchEnvironment(temporaryRoot) { + const config = join(temporaryRoot, 'config') + const data = join(temporaryRoot, 'data') + const cache = join(temporaryRoot, 'cache') + await Promise.all([mkdir(config), mkdir(data), mkdir(cache)]) + return { + ...process.env, + XDG_CONFIG_HOME: config, + XDG_DATA_HOME: data, + XDG_CACHE_HOME: cache, + NO_AT_BRIDGE: '1' + } +} + +async function assertPathsAbsent(paths) { + for (const path of paths) { + try { + await lstat(path) + throw new Error(`Disposable package smoke requires an absent path before/after install: ${path}`) + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } + } +} + +async function withCleanup(work, cleanup) { + let workError + try { + await work() + } catch (error) { + workError = error + } + + try { + await cleanup() + } catch (cleanupError) { + if (workError) { + throw new AggregateError([workError, cleanupError], `Package smoke and cleanup both failed: ${workError.message}; ${cleanupError.message}`) + } + throw cleanupError + } + + if (workError) throw workError +} + +async function runCommand(command, args, options = {}) { + await new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd: options.cwd ?? process.cwd(), + env: options.env ?? process.env, + stdio: 'inherit', + shell: false + }) + child.once('error', reject) + child.once('close', (code, signal) => { + if (code === 0) { + resolvePromise() + } else { + reject(new Error(`${command} ${args.join(' ')} exited with ${code ?? signal ?? 'unknown status'}`)) + } + }) + }) +} + +async function assertLaunchStaysRunning(command, args, { env, milliseconds, label }) { + const child = spawn(command, args, { + detached: process.platform !== 'win32', + env, + stdio: 'inherit', + shell: false + }) + const exit = new Promise(resolvePromise => { + child.once('error', error => resolvePromise({ error })) + child.once('exit', (code, signal) => resolvePromise({ code, signal })) + }) + const earlyExit = await Promise.race([ + exit, + new Promise(resolvePromise => setTimeout(() => resolvePromise(null), milliseconds)) + ]) + + if (earlyExit) { + if (earlyExit.error) throw earlyExit.error + throw new Error(`${label} exited before the ${milliseconds}ms launch smoke completed (code ${earlyExit.code ?? 'null'}, signal ${earlyExit.signal ?? 'none'})`) + } + + terminateProcessTree(child, 'SIGTERM') + const terminated = await Promise.race([ + exit.then(() => true), + new Promise(resolvePromise => setTimeout(() => resolvePromise(false), 5_000)) + ]) + if (!terminated) { + terminateProcessTree(child, 'SIGKILL') + await exit + } +} + +function terminateProcessTree(child, signal) { + try { + if (process.platform !== 'win32' && child.pid) { + process.kill(-child.pid, signal) + } else { + child.kill(signal) + } + } catch (error) { + if (error?.code !== 'ESRCH') throw error + } +} + +const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]) +if (isMainModule) { + main().catch(error => { + console.error(`error: ${error.message}`) + process.exitCode = 1 + }) +} + +export { + expectedAptSources, + selectLinuxDebAndAppImageArtifacts, + selectLinuxDesktopArtifacts, + selectLinuxRpmArtifact, + selectSingle, + smokeAptSetupPackage, + smokeLinuxDebAndAppImageArtifacts, + smokeLinuxRpmArtifact, + smokeMacosDmg, + verifyAptSetupInstallation +} diff --git a/scripts/smoke-release-artifacts.test.mjs b/scripts/smoke-release-artifacts.test.mjs new file mode 100644 index 0000000..90b8b44 --- /dev/null +++ b/scripts/smoke-release-artifacts.test.mjs @@ -0,0 +1,397 @@ +import assert from 'node:assert/strict' +import { access, chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { + expectedAptSources, + selectLinuxDesktopArtifacts, + selectSingle, + smokeAptSetupPackage, + smokeLinuxDebAndAppImageArtifacts, + smokeLinuxRpmArtifact, + smokeMacosDmg, + verifyAptSetupInstallation +} from './smoke-release-artifacts.mjs' + +test('selectLinuxDesktopArtifacts requires exactly one final desktop artifact of each format', () => { + assert.deepEqual( + selectLinuxDesktopArtifacts([ + 'qa-scribe_1.2.3_amd64.deb', + 'qa-scribe-repository-setup_1.0_all.deb', + 'qa-scribe-1.2.3-1.x86_64.rpm', + 'QA.Scribe_1.2.3_amd64.AppImage', + 'SHA256SUMS' + ]), + { + deb: 'qa-scribe_1.2.3_amd64.deb', + rpm: 'qa-scribe-1.2.3-1.x86_64.rpm', + appImage: 'QA.Scribe_1.2.3_amd64.AppImage' + } + ) +}) + +test('artifact selection rejects missing or duplicate final packages', () => { + assert.throws( + () => selectLinuxDesktopArtifacts(['qa-scribe_1.2.3_amd64.deb', 'QA.Scribe_1.2.3_amd64.AppImage']), + /exactly one desktop rpm artifact, found 0/ + ) + assert.throws( + () => selectSingle(['one.dmg', 'two.dmg'], name => name.endsWith('.dmg'), 'DMG'), + /exactly one DMG artifact, found 2/ + ) +}) + +test('expectedAptSources renders the strict installed Deb822 contract', () => { + assert.equal( + expectedAptSources({ + repositoryUrl: 'https://example.test/qa-scribe/apt/', + architectures: ['amd64'] + }), + [ + 'Types: deb', + 'URIs: https://example.test/qa-scribe/apt/', + 'Suites: stable', + 'Components: main', + 'Architectures: amd64', + 'Signed-By: /usr/share/keyrings/qa-scribe-archive-keyring.pgp', + '' + ].join('\n') + ) +}) + +async function aptSetupFixture() { + const directory = await mkdtemp(join(tmpdir(), 'qa-scribe-apt-setup-')) + const keyringPath = join(directory, 'installed-keyring.pgp') + const expectedKeyringPath = join(directory, 'release-keyring.pgp') + const sourcesPath = join(directory, 'qa-scribe.sources') + const keyring = Buffer.from([0x99, 0x01, 0x02, 0x03, 0x04]) + const repositoryUrl = 'https://example.test/qa-scribe/apt/' + const sources = expectedAptSources({ repositoryUrl, architectures: ['amd64'] }) + await writeFile(keyringPath, keyring) + await writeFile(expectedKeyringPath, keyring) + await writeFile(sourcesPath, sources, 'utf8') + await Promise.all([chmod(keyringPath, 0o644), chmod(expectedKeyringPath, 0o644), chmod(sourcesPath, 0o644)]) + return { directory, keyringPath, expectedKeyringPath, sourcesPath, repositoryUrl } +} + +test('verifyAptSetupInstallation checks installed modes and exact keyring/source content', async () => { + const fixture = await aptSetupFixture() + try { + await verifyAptSetupInstallation({ + keyringPath: fixture.keyringPath, + sourcesPath: fixture.sourcesPath, + expectedKeyringPath: fixture.expectedKeyringPath, + expectedRepositoryUrl: fixture.repositoryUrl, + architectures: ['amd64'], + requireRootOwnership: false + }) + } finally { + await rm(fixture.directory, { recursive: true, force: true }) + } +}) + +test('verifyAptSetupInstallation rejects permissive modes and changed source content', async (t) => { + await t.test('mode', async () => { + const fixture = await aptSetupFixture() + try { + await chmod(fixture.sourcesPath, 0o666) + await assert.rejects( + verifyAptSetupInstallation({ + keyringPath: fixture.keyringPath, + sourcesPath: fixture.sourcesPath, + expectedKeyringPath: fixture.expectedKeyringPath, + expectedRepositoryUrl: fixture.repositoryUrl, + architectures: ['amd64'], + requireRootOwnership: false + }), + /mode is 666, expected 644/ + ) + } finally { + await rm(fixture.directory, { recursive: true, force: true }) + } + }) + + await t.test('content', async () => { + const fixture = await aptSetupFixture() + try { + await writeFile(fixture.sourcesPath, (await readFile(fixture.sourcesPath, 'utf8')).replace('Suites: stable', 'Suites: testing')) + await assert.rejects( + verifyAptSetupInstallation({ + keyringPath: fixture.keyringPath, + sourcesPath: fixture.sourcesPath, + expectedKeyringPath: fixture.expectedKeyringPath, + expectedRepositoryUrl: fixture.repositoryUrl, + architectures: ['amd64'], + requireRootOwnership: false + }), + /sources content differs/ + ) + } finally { + await rm(fixture.directory, { recursive: true, force: true }) + } + }) +}) + +test('Ubuntu smoke installs and removes the deb before executing AppImage', async () => { + const directory = await mkdtemp(join(tmpdir(), 'qa-scribe-linux-smoke-plan-')) + const deb = join(directory, 'qa-scribe_1.2.3_amd64.deb') + const rpm = join(directory, 'qa-scribe-1.2.3-1.x86_64.rpm') + const appImage = join(directory, 'QA.Scribe_1.2.3_amd64.AppImage') + const commands = [] + const launches = [] + const absenceChecks = [] + try { + await Promise.all([writeFile(deb, ''), writeFile(rpm, ''), writeFile(appImage, '')]) + await chmod(appImage, 0o755) + + await smokeLinuxDebAndAppImageArtifacts({ + artifactDirectory: directory, + platform: 'linux', + launchMilliseconds: 1234, + commandRunner: async (command, args) => commands.push([command, args]), + launcher: async (command, args, options) => launches.push([command, args, options]), + pathAbsenceChecker: async paths => absenceChecks.push(paths) + }) + + assert.deepEqual(commands, [ + ['sudo', ['apt-get', 'install', '-y', '--no-install-recommends', deb]], + ['sudo', ['dpkg', '--purge', 'qa-scribe']] + ]) + assert.equal(launches.length, 2) + assert.deepEqual(launches.map(([command]) => command), ['xvfb-run', 'xvfb-run']) + assert.equal(launches[0][1].at(-1), '/usr/bin/qa-scribe') + assert.equal(launches[1][1].at(-1), appImage) + assert.equal(launches[1][2].env.APPIMAGE_EXTRACT_AND_RUN, '1') + assert.ok(launches.every(([, , options]) => options.milliseconds === 1234)) + assert.equal(absenceChecks.length, 2) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + +test('Fedora RPM smoke uses dnf dependency resolution and never --nodeps', async () => { + const directory = await mkdtemp(join(tmpdir(), 'qa-scribe-rpm-smoke-plan-')) + const rpm = join(directory, 'qa-scribe-1.2.3-1.x86_64.rpm') + const commands = [] + const launches = [] + const absenceChecks = [] + try { + await writeFile(rpm, '') + await smokeLinuxRpmArtifact({ + artifactDirectory: directory, + platform: 'linux', + launchMilliseconds: 2345, + commandRunner: async (command, args) => commands.push([command, args]), + launcher: async (command, args, options) => launches.push([command, args, options]), + pathAbsenceChecker: async paths => absenceChecks.push(paths) + }) + + assert.deepEqual(commands, [ + ['dnf', ['install', '-y', '--setopt=install_weak_deps=False', rpm]], + ['dnf', ['install', '-y', 'xorg-x11-server-Xvfb', 'xorg-x11-xauth']], + ['dnf', ['remove', '-y', 'qa-scribe']] + ]) + assert.ok(commands.every(([, args]) => !args.includes('--nodeps'))) + assert.equal(launches.length, 1) + assert.equal(launches[0][0], 'xvfb-run') + assert.equal(launches[0][1].at(-1), '/usr/bin/qa-scribe') + assert.equal(launches[0][2].milliseconds, 2345) + assert.equal(absenceChecks.length, 2) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + +test('APT setup smoke installs the actual deb, verifies installed paths, then purges it', async () => { + const directory = await mkdtemp(join(tmpdir(), 'qa-scribe-apt-smoke-plan-')) + const setupDeb = join(directory, 'qa-scribe-repository-setup_1.0_all.deb') + const keyring = join(directory, 'qa-scribe-archive-keyring.pgp') + const commands = [] + const absenceChecks = [] + let verification + try { + await Promise.all([writeFile(setupDeb, 'deb'), writeFile(keyring, 'keyring')]) + await smokeAptSetupPackage({ + setupDebPath: setupDeb, + expectedKeyringPath: keyring, + platform: 'linux', + commandRunner: async (command, args) => commands.push([command, args]), + pathAbsenceChecker: async paths => absenceChecks.push(paths), + verification: async options => { verification = options } + }) + + assert.deepEqual(commands, [ + ['sudo', ['apt-get', 'install', '-y', '--no-install-recommends', setupDeb]], + ['sudo', ['dpkg', '--purge', 'qa-scribe-repository-setup']] + ]) + assert.equal(verification.expectedKeyringPath, keyring) + assert.equal(verification.keyringPath, '/usr/share/keyrings/qa-scribe-archive-keyring.pgp') + assert.equal(verification.sourcesPath, '/etc/apt/sources.list.d/qa-scribe.sources') + assert.equal(verification.requireRootOwnership, true) + assert.deepEqual(verification.architectures, ['amd64']) + assert.equal(absenceChecks.length, 2) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + +test('APT setup smoke still purges the installed package when verification fails', async () => { + const directory = await mkdtemp(join(tmpdir(), 'qa-scribe-apt-smoke-failure-')) + const setupDeb = join(directory, 'qa-scribe-repository-setup_1.0_all.deb') + const keyring = join(directory, 'qa-scribe-archive-keyring.pgp') + const commands = [] + try { + await Promise.all([writeFile(setupDeb, 'deb'), writeFile(keyring, 'keyring')]) + await assert.rejects( + smokeAptSetupPackage({ + setupDebPath: setupDeb, + expectedKeyringPath: keyring, + platform: 'linux', + commandRunner: async (command, args) => commands.push([command, args]), + pathAbsenceChecker: async () => {}, + verification: async () => { throw new Error('injected installed-file mismatch') } + }), + /injected installed-file mismatch/ + ) + assert.deepEqual(commands.map(([, args]) => args.slice(0, 2)), [ + ['apt-get', 'install'], + ['dpkg', '--purge'] + ]) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + +test('macOS smoke mounts the DMG, copies its app, launches the copied executable, and detaches', async () => { + const directory = await mkdtemp(join(tmpdir(), 'qa-scribe-macos-smoke-plan-')) + const dmg = join(directory, 'QA.Scribe_1.2.3_aarch64.dmg') + const commands = [] + const launches = [] + try { + await writeFile(dmg, 'dmg') + await smokeMacosDmg({ + artifactDirectory: directory, + platform: 'darwin', + launchMilliseconds: 2345, + commandRunner: async (command, args) => { + commands.push([command, args]) + if (command === 'hdiutil' && args[0] === 'attach') { + const mountPath = args.at(-1) + const executable = join(mountPath, 'QA Scribe.app', 'Contents', 'MacOS', 'qa-scribe') + await mkdir(join(executable, '..'), { recursive: true }) + await writeFile(executable, '#!/bin/sh\n') + await chmod(executable, 0o755) + } + if (command === 'ditto') { + const executable = join(args[1], 'Contents', 'MacOS', 'qa-scribe') + await mkdir(join(executable, '..'), { recursive: true }) + await writeFile(executable, '#!/bin/sh\n') + await chmod(executable, 0o755) + } + }, + launcher: async (command, args, options) => launches.push([command, args, options]) + }) + + assert.deepEqual(commands.map(([command]) => command), ['hdiutil', 'ditto', 'hdiutil']) + assert.equal(commands[0][1][0], 'attach') + assert.equal(commands[0][1][1], dmg) + assert.equal(commands[0][1][2], '-readonly') + assert.equal(commands[2][1][0], 'detach') + assert.match(commands[1][1][0], /mounted\/QA Scribe\.app$/) + assert.match(commands[1][1][1], /copied\/QA Scribe\.app$/) + assert.equal(launches.length, 1) + assert.match(launches[0][0], /copied\/QA Scribe\.app\/Contents\/MacOS\/qa-scribe$/) + assert.deepEqual(launches[0][1], []) + assert.equal(launches[0][2].milliseconds, 2345) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + +test('macOS smoke removes its temporary root when launch and detach both fail', async () => { + const directory = await mkdtemp(join(tmpdir(), 'qa-scribe-macos-smoke-cleanup-')) + const dmg = join(directory, 'QA.Scribe_1.2.3_aarch64.dmg') + let temporaryRoot + try { + await writeFile(dmg, 'dmg') + await assert.rejects( + smokeMacosDmg({ + artifactDirectory: directory, + platform: 'darwin', + commandRunner: async (command, args) => { + if (command === 'hdiutil' && args[0] === 'attach') { + const mountPath = args.at(-1) + temporaryRoot = join(mountPath, '..') + const executable = join(mountPath, 'QA Scribe.app', 'Contents', 'MacOS', 'qa-scribe') + await mkdir(join(executable, '..'), { recursive: true }) + await writeFile(executable, '#!/bin/sh\n') + await chmod(executable, 0o755) + } + if (command === 'ditto') { + const executable = join(args[1], 'Contents', 'MacOS', 'qa-scribe') + await mkdir(join(executable, '..'), { recursive: true }) + await writeFile(executable, '#!/bin/sh\n') + await chmod(executable, 0o755) + } + if (command === 'hdiutil' && args[0] === 'detach') { + throw new Error('injected detach failure') + } + }, + launcher: async () => { throw new Error('injected launch failure') } + }), + error => { + assert.ok(error instanceof AggregateError) + assert.match(error.message, /injected launch failure/) + assert.match(error.message, /injected detach failure/) + return true + } + ) + await assert.rejects(access(temporaryRoot), { code: 'ENOENT' }) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + +test('CI and release workflows separate Ubuntu smoke from pinned Fedora RPM smoke', async () => { + const [ci, release] = await Promise.all([ + readFile('.github/workflows/ci.yml', 'utf8'), + readFile('.github/workflows/release.yml', 'utf8') + ]) + + for (const workflow of [ci, release]) { + assert.match(workflow, /RPM_SMOKE_IMAGE: fedora:43@sha256:[a-f0-9]{64}/) + assert.match(workflow, /node scripts\/smoke-release-artifacts\.mjs --linux-deb-appimage dist\/rust\/artifacts/) + assert.match(workflow, /docker run --rm/) + assert.match(workflow, /--volume "\$\{PWD\}:\/workspace:ro"/) + assert.match(workflow, /--volume "\$\(command -v bun\):\/usr\/local\/bin\/bun:ro"/) + assert.match(workflow, /bun scripts\/smoke-release-artifacts\.mjs --linux-rpm dist\/rust\/artifacts/) + assert.doesNotMatch(workflow, /dnf install -y (?:nodejs|xorg-x11)/) + assert.doesNotMatch(workflow, /--linux-desktop/) + } + assert.match( + release, + /node scripts\/smoke-release-artifacts\.mjs --apt-setup dist\/rust\/artifacts\/qa-scribe-repository-setup_1\.0_all\.deb --expected-keyring dist\/rust\/artifacts\/qa-scribe-archive-keyring\.pgp/ + ) + assert.match(release, /node scripts\/smoke-release-artifacts\.mjs --macos-dmg dist\/rust\/artifacts/) + + const macSmoke = release.indexOf('node scripts/smoke-release-artifacts.mjs --macos-dmg') + assert.ok(macSmoke > release.indexOf('Sign and notarize macOS app bundle')) + assert.ok(macSmoke > release.indexOf('Build, sign, and notarize macOS DMG')) + assert.ok(macSmoke > release.indexOf('Clean up Apple signing environment')) + assert.ok(macSmoke < release.indexOf('Stage macOS release assets')) + + const aptSmoke = release.indexOf('node scripts/smoke-release-artifacts.mjs --apt-setup') + assert.ok(aptSmoke > release.indexOf('Build signed APT repository and checksums')) + assert.ok(aptSmoke < release.indexOf('Stage Linux release assets')) + + const captureKeychains = release.indexOf('qa-scribe-previous-keychains.txt') + const replaceKeychains = release.indexOf('security list-keychains -d user -s "$keychain_path"') + const restoreKeychains = release.indexOf('security list-keychains -d user -s "${previous_keychains[@]}"') + const deleteKeychain = release.indexOf('security delete-keychain "${keychain_path}"') + assert.ok(captureKeychains > 0) + assert.ok(replaceKeychains > captureKeychains) + assert.ok(restoreKeychains > replaceKeychains) + assert.ok(deleteKeychain > restoreKeychains) + assert.doesNotMatch(release, /security default-keychain -d user -s "\$keychain_path"/) +}) diff --git a/scripts/tool-versions.json b/scripts/tool-versions.json index 1318b98..bc0c208 100644 --- a/scripts/tool-versions.json +++ b/scripts/tool-versions.json @@ -1,3 +1,4 @@ { - "tauriCli": "2.11.4" + "tauriCli": "2.11.4", + "cargoAudit": "0.22.2" } diff --git a/scripts/version-transaction.mjs b/scripts/version-transaction.mjs new file mode 100644 index 0000000..5e2cca4 --- /dev/null +++ b/scripts/version-transaction.mjs @@ -0,0 +1,283 @@ +import { chmod, lstat, open, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { createHash, randomUUID } from 'node:crypto' +import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path' + +const VERSION_TRANSACTION_MANIFEST = '.qa-scribe-version-transaction.json' + +/** + * Apply a complete version plan as a process-interruption-safe transaction. + * The phase manifest is written before staging and atomically replaced before + * commit, so a later invocation can restore or clean up partial work. + */ +export async function applyPlanTransaction(plan, options = {}) { + const rootDir = resolve(options.rootDir ?? process.cwd()) + const replace = options.replace ?? atomicReplace + const transactionId = options.transactionId ?? randomUUID() + await recoverInterruptedVersionTransaction({ rootDir, replace }) + + const staged = [] + const seenTargets = new Set() + for (const [index, change] of plan.entries()) { + const targetPath = resolvePlanTarget(rootDir, change.path) + if (seenTargets.has(targetPath)) { + throw new Error(`Version bump plan contains duplicate target ${change.path}`) + } + seenTargets.add(targetPath) + + const currentContent = await readFile(targetPath, 'utf8') + if (typeof change.previousContent === 'string' && currentContent !== change.previousContent) { + throw new Error(`Version bump target changed after preflight: ${change.path}`) + } + const targetStat = await lstat(targetPath) + if (!targetStat.isFile()) { + throw new Error(`Version bump target is not a regular file: ${change.path}`) + } + + const prefix = `.${basename(targetPath)}.qa-scribe-bump-${transactionId}-${index}` + staged.push({ + ...change, + originalContent: currentContent, + previousDigest: contentDigest(currentContent), + nextDigest: contentDigest(change.nextContent), + targetPath, + stagedPath: resolve(dirname(targetPath), `${prefix}.next`), + rollbackPath: resolve(dirname(targetPath), `${prefix}.rollback`), + mode: targetStat.mode & 0o777 + }) + } + + const manifest = transactionManifest(rootDir, transactionId, 'staging', staged) + await writeTransactionManifest(rootDir, manifest) + try { + for (const item of staged) { + await writeFile(item.stagedPath, item.nextContent, { encoding: 'utf8', flag: 'wx', mode: item.mode }) + await chmod(item.stagedPath, item.mode) + await writeFile(item.rollbackPath, item.originalContent, { encoding: 'utf8', flag: 'wx', mode: item.mode }) + await chmod(item.rollbackPath, item.mode) + } + manifest.phase = 'committing' + await writeTransactionManifest(rootDir, manifest) + } catch (error) { + await cleanupTransactionFiles(staged) + await removeTransactionManifest(rootDir) + throw error + } + + const replaced = [] + try { + for (const item of staged) { + const currentContent = await readFile(item.targetPath, 'utf8') + if (currentContent !== item.originalContent) { + throw new Error(`Version bump target changed after staging: ${item.path}`) + } + await replace(item.stagedPath, item.targetPath, { phase: 'commit', item }) + replaced.push(item) + } + manifest.phase = 'committed' + await writeTransactionManifest(rootDir, manifest) + } catch (commitError) { + const rollbackFailures = await rollbackTransactionItems([...replaced].reverse(), replace) + if (rollbackFailures.length > 0) { + throw new AggregateError( + [commitError, ...rollbackFailures.map(({ error }) => error)], + 'Version bump replacement failed and rollback was incomplete. The next invocation will retry recovery from the preserved transaction manifest.', + { cause: commitError } + ) + } + await cleanupTransactionFiles(staged) + await removeTransactionManifest(rootDir) + throw new Error(`Version bump replacement failed; all replaced files were restored: ${commitError.message}`, { cause: commitError }) + } + + await cleanupTransactionFiles(staged) + await removeTransactionManifest(rootDir) +} + +export async function recoverInterruptedVersionTransaction(options = {}) { + const rootDir = resolve(options.rootDir ?? process.cwd()) + const replace = options.replace ?? atomicReplace + const manifestPath = resolve(rootDir, VERSION_TRANSACTION_MANIFEST) + await rm(`${manifestPath}.next`, { force: true }) + const rawManifest = await readFileIfExists(manifestPath) + if (rawManifest == null) return false + + const manifest = parseTransactionManifest(rawManifest) + const items = manifest.items.map(item => ({ + ...item, + targetPath: resolveTransactionPath(rootDir, item.targetPath), + stagedPath: resolveTransactionPath(rootDir, item.stagedPath), + rollbackPath: resolveTransactionPath(rootDir, item.rollbackPath) + })) + + if (manifest.phase === 'committing') { + const rollbackFailures = [] + for (const item of [...items].reverse()) { + const currentContent = await readFileIfExists(item.targetPath) + if (currentContent != null && contentDigest(currentContent) === item.previousDigest) continue + if (currentContent == null || contentDigest(currentContent) !== item.nextDigest) { + rollbackFailures.push({ + item, + error: new Error(`Version bump target changed after interruption: ${item.path}`) + }) + continue + } + const rollbackContent = await readFileIfExists(item.rollbackPath) + if (rollbackContent != null) { + if (contentDigest(rollbackContent) !== item.previousDigest) { + rollbackFailures.push({ + item, + error: new Error(`Rollback copy for ${item.path} does not contain the prior content`) + }) + continue + } + try { + await replace(item.rollbackPath, item.targetPath, { phase: 'recovery', item }) + } catch (error) { + rollbackFailures.push({ item, error }) + } + continue + } + rollbackFailures.push({ + item, + error: new Error(`Missing rollback copy for committed version bump target ${item.path}`) + }) + } + if (rollbackFailures.length > 0) { + throw new AggregateError( + rollbackFailures.map(({ error }) => error), + 'Interrupted version bump recovery is incomplete; transaction files were preserved for another recovery attempt.' + ) + } + } + + await cleanupTransactionFiles(items) + await removeTransactionManifest(rootDir) + return true +} + +async function atomicReplace(stagedPath, targetPath) { + await rename(stagedPath, targetPath) +} + +async function rollbackTransactionItems(items, replace) { + const failures = [] + for (const item of items) { + try { + const currentContent = await readFileIfExists(item.targetPath) + if (currentContent == null || contentDigest(currentContent) !== item.nextDigest) { + throw new Error(`Version bump target changed before rollback: ${item.path}`) + } + await replace(item.rollbackPath, item.targetPath, { phase: 'rollback', item }) + } catch (error) { + failures.push({ item, error }) + } + } + return failures +} + +async function cleanupTransactionFiles(staged) { + const cleanupErrors = [] + for (const item of staged) { + for (const path of [item.stagedPath, item.rollbackPath]) { + try { + await rm(path, { force: true }) + } catch (error) { + cleanupErrors.push(`${path}: ${error.message}`) + } + } + } + if (cleanupErrors.length > 0) { + throw new Error(`Could not clean version bump transaction files:\n${cleanupErrors.map(message => ` - ${message}`).join('\n')}`) + } +} + +function transactionManifest(rootDir, transactionId, phase, items) { + return { + version: 2, + transactionId, + phase, + items: items.map(item => ({ + path: item.path, + targetPath: relative(rootDir, item.targetPath), + stagedPath: relative(rootDir, item.stagedPath), + rollbackPath: relative(rootDir, item.rollbackPath), + previousDigest: item.previousDigest, + nextDigest: item.nextDigest + })) + } +} + +async function writeTransactionManifest(rootDir, manifest) { + const manifestPath = resolve(rootDir, VERSION_TRANSACTION_MANIFEST) + const nextPath = `${manifestPath}.next` + const handle = await open(nextPath, 'w', 0o600) + try { + await handle.writeFile(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8') + await handle.sync() + } finally { + await handle.close() + } + await rename(nextPath, manifestPath) +} + +async function removeTransactionManifest(rootDir) { + const manifestPath = resolve(rootDir, VERSION_TRANSACTION_MANIFEST) + await rm(manifestPath, { force: true }) + await rm(`${manifestPath}.next`, { force: true }) +} + +async function readFileIfExists(path) { + try { + return await readFile(path, 'utf8') + } catch (error) { + if (error?.code === 'ENOENT') return null + throw error + } +} + +function parseTransactionManifest(rawManifest) { + let manifest + try { + manifest = JSON.parse(rawManifest) + } catch (error) { + throw new Error(`Version transaction manifest is not valid JSON: ${error.message}`) + } + if ( + manifest?.version !== 2 || + !['staging', 'committing', 'committed'].includes(manifest.phase) || + !Array.isArray(manifest.items) + ) { + throw new Error('Version transaction manifest has an unsupported shape') + } + for (const item of manifest.items) { + if ( + typeof item?.path !== 'string' || + typeof item.targetPath !== 'string' || + typeof item.stagedPath !== 'string' || + typeof item.rollbackPath !== 'string' || + !/^[a-f0-9]{64}$/.test(item.previousDigest) || + !/^[a-f0-9]{64}$/.test(item.nextDigest) + ) { + throw new Error('Version transaction manifest contains an invalid item') + } + } + return manifest +} + +function resolvePlanTarget(rootDir, path) { + const targetPath = resolve(rootDir, path) + const relativePath = relative(rootDir, targetPath) + if (relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) { + throw new Error(`Version bump target is outside the repository: ${path}`) + } + return targetPath +} + +function resolveTransactionPath(rootDir, path) { + if (isAbsolute(path)) throw new Error('Version transaction manifest paths must be relative') + return resolvePlanTarget(rootDir, path) +} + +function contentDigest(content) { + return createHash('sha256').update(content).digest('hex') +} diff --git a/scripts/version-transaction.test.mjs b/scripts/version-transaction.test.mjs new file mode 100644 index 0000000..8be05cd --- /dev/null +++ b/scripts/version-transaction.test.mjs @@ -0,0 +1,319 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { mkdtemp, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import test from 'node:test' + +import { buildPlan, cargoLockCrateVersions } from './bump-version.mjs' +import { applyPlanTransaction, recoverInterruptedVersionTransaction } from './version-transaction.mjs' + +const BUMP_SCRIPT_PATH = resolve('scripts/bump-version.mjs') +const PATHS = [ + 'package.json', + 'frontend/package.json', + 'Cargo.toml', + 'Cargo.lock', + 'src-tauri/tauri.conf.json', + 'CHANGELOG.md', + 'build/linux/io.github.ddv1982.qa-scribe.metainfo.xml' +] + +async function makeFixture() { + const rootDir = await mkdtemp(join(tmpdir(), 'qa-scribe-version-transaction-')) + const plan = [] + for (const [index, path] of PATHS.entries()) { + const target = join(rootDir, path) + await mkdir(dirname(target), { recursive: true }) + await writeFile(target, `old-${index}\n`) + plan.push({ path, description: `fixture ${index}`, previousContent: `old-${index}\n`, nextContent: `new-${index}\n` }) + } + return { rootDir, plan } +} + +async function assertContents(rootDir, prefix) { + for (const [index, path] of PATHS.entries()) { + assert.equal(await readFile(join(rootDir, path), 'utf8'), `${prefix}-${index}\n`, path) + } +} + +async function assertNoTransactionFiles(rootDir) { + async function walk(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + if (entry.isDirectory()) await walk(path) + else assert.doesNotMatch(entry.name, /\.qa-scribe-(?:bump-|version-transaction)/, path) + } + } + await walk(rootDir) +} + +test('every output is staged before the first destination replacement', async () => { + const { rootDir, plan } = await makeFixture() + let checked = false + try { + await applyPlanTransaction(plan, { + rootDir, + transactionId: 'all-staged', + replace: async (source, target, context) => { + if (!checked && context.phase === 'commit') { + for (const [index, path] of PATHS.entries()) { + const prefix = `.${path.split('/').at(-1)}.qa-scribe-bump-all-staged-${index}` + const names = await readdir(dirname(join(rootDir, path))) + assert.ok(names.includes(`${prefix}.next`), `${path} next content`) + assert.ok(names.includes(`${prefix}.rollback`), `${path} rollback content`) + } + checked = true + } + await rename(source, target) + } + }) + assert.equal(checked, true) + await assertContents(rootDir, 'new') + await assertNoTransactionFiles(rootDir) + } finally { + await rm(rootDir, { recursive: true, force: true }) + } +}) + +test('a replacement failure at every position restores all prior files', async (t) => { + for (let failureIndex = 0; failureIndex < PATHS.length; failureIndex += 1) { + await t.test(`replacement ${failureIndex + 1}`, async () => { + const { rootDir, plan } = await makeFixture() + let commitIndex = 0 + try { + await assert.rejects(applyPlanTransaction(plan, { + rootDir, + transactionId: `failure-${failureIndex}`, + replace: async (source, target, context) => { + if (context.phase === 'commit' && commitIndex === failureIndex) throw new Error('injected failure') + if (context.phase === 'commit') commitIndex += 1 + await rename(source, target) + } + }), /all replaced files were restored/) + await assertContents(rootDir, 'old') + await assertNoTransactionFiles(rootDir) + } finally { + await rm(rootDir, { recursive: true, force: true }) + } + }) + } +}) + +test('changes before and after staging never overwrite concurrent edits', async () => { + const first = await makeFixture() + try { + await writeFile(join(first.rootDir, PATHS[3]), 'concurrent-edit\n') + await assert.rejects(applyPlanTransaction(first.plan, { rootDir: first.rootDir }), /changed after preflight/) + assert.equal(await readFile(join(first.rootDir, PATHS[3]), 'utf8'), 'concurrent-edit\n') + await assertNoTransactionFiles(first.rootDir) + } finally { + await rm(first.rootDir, { recursive: true, force: true }) + } + + const second = await makeFixture() + let firstCommit = true + try { + await assert.rejects(applyPlanTransaction(second.plan, { + rootDir: second.rootDir, + transactionId: 'changed-after-staging', + replace: async (source, target, context) => { + await rename(source, target) + if (context.phase === 'commit' && firstCommit) { + firstCommit = false + await writeFile(join(second.rootDir, PATHS[1]), 'concurrent-edit\n') + } + } + }), /target changed after staging/) + assert.equal(await readFile(join(second.rootDir, PATHS[1]), 'utf8'), 'concurrent-edit\n') + await assertNoTransactionFiles(second.rootDir) + } finally { + await rm(second.rootDir, { recursive: true, force: true }) + } +}) + +test('a failed immediate rollback is recovered automatically on the next invocation', async () => { + const { rootDir, plan } = await makeFixture() + let commitIndex = 0 + try { + await assert.rejects(applyPlanTransaction(plan, { + rootDir, + transactionId: 'rollback-recovery', + replace: async (source, target, context) => { + if (context.phase === 'commit' && commitIndex === 3) throw new Error('commit failure') + if (context.phase === 'commit') commitIndex += 1 + if (context.phase === 'rollback' && context.item.path === PATHS[1]) throw new Error('rollback failure') + await rename(source, target) + } + }), /next invocation will retry recovery/) + assert.equal(await recoverInterruptedVersionTransaction({ rootDir }), true) + await assertContents(rootDir, 'old') + await assertNoTransactionFiles(rootDir) + } finally { + await rm(rootDir, { recursive: true, force: true }) + } +}) + +test('interrupted recovery refuses to overwrite a later edit', async () => { + const { rootDir } = await makeFixture() + const items = [] + try { + for (const [index, path] of PATHS.entries()) { + const target = join(rootDir, path) + const prefix = `.${path.split('/').at(-1)}.qa-scribe-bump-conflict-${index}` + const staged = join(dirname(target), `${prefix}.next`) + const rollback = join(dirname(target), `${prefix}.rollback`) + await writeFile(staged, `new-${index}\n`) + await writeFile(rollback, `old-${index}\n`) + items.push({ + path, + targetPath: path, + stagedPath: join(dirname(path), `${prefix}.next`), + rollbackPath: join(dirname(path), `${prefix}.rollback`), + previousDigest: createHash('sha256').update(`old-${index}\n`).digest('hex'), + nextDigest: createHash('sha256').update(`new-${index}\n`).digest('hex') + }) + } + await writeFile(join(rootDir, PATHS[0]), 'new-0\n') + await writeFile(join(rootDir, PATHS[3]), 'edited-after-interruption\n') + await writeFile(join(rootDir, '.qa-scribe-version-transaction.json'), JSON.stringify({ + version: 2, + transactionId: 'conflict', + phase: 'committing', + items + })) + + await assert.rejects(recoverInterruptedVersionTransaction({ rootDir }), error => { + assert.ok(error instanceof AggregateError) + assert.ok(error.errors.some(item => /target changed after interruption: Cargo.lock/.test(item.message))) + return true + }) + assert.equal(await readFile(join(rootDir, PATHS[0]), 'utf8'), 'old-0\n') + assert.equal(await readFile(join(rootDir, PATHS[3]), 'utf8'), 'edited-after-interruption\n') + assert.equal(await readFile(join(rootDir, '.qa-scribe-version-transaction.json'), 'utf8').then(Boolean), true) + } finally { + await rm(rootDir, { recursive: true, force: true }) + } +}) + +test('completed transaction recovery keeps new files and only cleans temporary state', async () => { + const { rootDir } = await makeFixture() + const items = [] + try { + for (const [index, path] of PATHS.entries()) { + const target = join(rootDir, path) + const prefix = `.${path.split('/').at(-1)}.qa-scribe-bump-committed-${index}` + const staged = join(dirname(target), `${prefix}.next`) + const rollback = join(dirname(target), `${prefix}.rollback`) + await writeFile(target, `new-${index}\n`) + await writeFile(staged, `new-${index}\n`) + await writeFile(rollback, `old-${index}\n`) + items.push({ + path, + targetPath: path, + stagedPath: join(dirname(path), `${prefix}.next`), + rollbackPath: join(dirname(path), `${prefix}.rollback`), + previousDigest: createHash('sha256').update(`old-${index}\n`).digest('hex'), + nextDigest: createHash('sha256').update(`new-${index}\n`).digest('hex') + }) + } + await writeFile(join(rootDir, '.qa-scribe-version-transaction.json'), JSON.stringify({ + version: 2, + transactionId: 'committed', + phase: 'committed', + items + })) + assert.equal(await recoverInterruptedVersionTransaction({ rootDir }), true) + await assertContents(rootDir, 'new') + await assertNoTransactionFiles(rootDir) + } finally { + await rm(rootDir, { recursive: true, force: true }) + } +}) + +const CARGO_TOML = `[workspace]\nmembers = []\n\n[workspace.package]\nversion = "0.4.24"\n` +const CARGO_LOCK = ['qa-scribe-app', 'qa-scribe-core', 'qa-scribe-tauri'] + .map(name => `[[package]]\nname = "${name}"\nversion = "0.4.24"\n`) + .join('\n') +const CHANGELOG = '# Changelog\n\n## v0.4.24 - 2026-07-02\n\n- Prior.\n' +const METAINFO = '\n \n \n' + +async function writeCliFixture(rootDir) { + const packageJsonRaw = `${JSON.stringify({ name: 'qa-scribe', version: '0.4.24' }, null, 2)}\n` + const frontendPackageJsonRaw = `${JSON.stringify({ name: 'frontend', version: '0.4.24' }, null, 2)}\n` + const tauriConfRaw = `${JSON.stringify({ version: '0.4.24', identifier: 'io.github.ddv1982.qa-scribe' }, null, 2)}\n` + const contents = [packageJsonRaw, frontendPackageJsonRaw, CARGO_TOML, CARGO_LOCK, tauriConfRaw, CHANGELOG, METAINFO] + for (const [index, path] of PATHS.entries()) await writeFile(join(rootDir, path), contents[index]) + return buildPlan({ + packageJsonRaw, + packageJson: JSON.parse(packageJsonRaw), + frontendPackageJsonRaw, + frontendPackageJson: JSON.parse(frontendPackageJsonRaw), + cargoToml: CARGO_TOML, + cargoLock: CARGO_LOCK, + tauriConfRaw, + tauriConf: JSON.parse(tauriConfRaw), + changelog: CHANGELOG, + metainfo: METAINFO + }, { + newVersion: '0.5.0', + today: '2026-07-21', + metainfoPath: PATHS[6] + }) +} + +test('CLI dry-run leaves every version-bearing file unchanged', async () => { + const { rootDir } = await makeFixture() + try { + await writeCliFixture(rootDir) + const before = await Promise.all(PATHS.map(path => readFile(join(rootDir, path), 'utf8'))) + const result = spawnSync(process.execPath, [BUMP_SCRIPT_PATH, '0.5.0', '--dry-run'], { cwd: rootDir, encoding: 'utf8' }) + assert.equal(result.status, 0, result.stderr) + assert.deepEqual(await Promise.all(PATHS.map(path => readFile(join(rootDir, path), 'utf8'))), before) + await assertNoTransactionFiles(rootDir) + } finally { + await rm(rootDir, { recursive: true, force: true }) + } +}) + +test('the next CLI invocation rolls back a process killed between replacements', { skip: process.platform === 'win32' }, async () => { + const { rootDir } = await makeFixture() + try { + const plan = await writeCliFixture(rootDir) + const planPath = join(rootDir, 'interrupt-plan.json') + await writeFile(planPath, JSON.stringify(plan)) + const childScript = ` + import { rename, readFile } from 'node:fs/promises'; + import { applyPlanTransaction } from ${JSON.stringify(new URL('./version-transaction.mjs', import.meta.url).href)}; + const plan = JSON.parse(await readFile(process.env.QA_SCRIBE_TEST_PLAN, 'utf8')); + let replacements = 0; + await applyPlanTransaction(plan, { + rootDir: process.env.QA_SCRIBE_TEST_ROOT, + transactionId: 'process-interruption', + replace: async (source, target, context) => { + await rename(source, target); + if (context.phase === 'commit' && ++replacements === 2) process.kill(process.pid, 'SIGKILL'); + } + }); + ` + const interrupted = spawnSync(process.execPath, ['--input-type=module', '--eval', childScript], { + env: { ...process.env, QA_SCRIBE_TEST_PLAN: planPath, QA_SCRIBE_TEST_ROOT: rootDir }, + encoding: 'utf8' + }) + assert.equal(interrupted.signal, 'SIGKILL', interrupted.stderr) + + const recovery = spawnSync(process.execPath, [BUMP_SCRIPT_PATH, '0.5.0', '--dry-run'], { cwd: rootDir, encoding: 'utf8' }) + assert.equal(recovery.status, 0, recovery.stderr) + assert.match(recovery.stdout, /Recovered an interrupted version bump before preflight/) + assert.equal(JSON.parse(await readFile(join(rootDir, 'package.json'), 'utf8')).version, '0.4.24') + assert.deepEqual(cargoLockCrateVersions(await readFile(join(rootDir, 'Cargo.lock'), 'utf8')), { + 'qa-scribe-app': '0.4.24', + 'qa-scribe-core': '0.4.24', + 'qa-scribe-tauri': '0.4.24' + }) + await assertNoTransactionFiles(rootDir) + } finally { + await rm(rootDir, { recursive: true, force: true }) + } +}) diff --git a/src-tauri/build.rs b/src-tauri/build.rs index b33e60c..a5f617f 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -33,6 +33,7 @@ fn main() { "list_active_ai_action_jobs", "cancel_ai_action_job", "import_clipboard_screenshot", + "delete_attachment", "get_provider_status", "refresh_provider_status", "get_attachment_preview_data_url", diff --git a/src-tauri/permissions/autogenerated/delete_attachment.toml b/src-tauri/permissions/autogenerated/delete_attachment.toml new file mode 100644 index 0000000..798308f --- /dev/null +++ b/src-tauri/permissions/autogenerated/delete_attachment.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-delete-attachment" +description = "Enables the delete_attachment command without any pre-configured scope." +commands.allow = ["delete_attachment"] + +[[permission]] +identifier = "deny-delete-attachment" +description = "Denies the delete_attachment command without any pre-configured scope." +commands.deny = ["delete_attachment"] diff --git a/src-tauri/permissions/default.toml b/src-tauri/permissions/default.toml index e22ec68..c18f01a 100644 --- a/src-tauri/permissions/default.toml +++ b/src-tauri/permissions/default.toml @@ -30,6 +30,7 @@ permissions = [ "allow-list-active-ai-action-jobs", "allow-cancel-ai-action-job", "allow-import-clipboard-screenshot", + "allow-delete-attachment", "allow-get-provider-status", "allow-refresh-provider-status", "allow-get-attachment-preview-data-url", diff --git a/src-tauri/src/commands/ai/job_events.rs b/src-tauri/src/commands/ai/job_events.rs index 2f20b1c..9c6fc29 100644 --- a/src-tauri/src/commands/ai/job_events.rs +++ b/src-tauri/src/commands/ai/job_events.rs @@ -75,11 +75,13 @@ pub(super) fn finish_cancelled_job( state: &AppState, job_id: &str, request: &GenerateAiActionRequest, - ai_run_id: &str, + ai_run_id: Option<&str>, ) { - let ai_run = state - .with_service(|service| service.fail_ai_run(ai_run_id, "Generation cancelled.")) - .ok(); + let ai_run = ai_run_id.and_then(|ai_run_id| { + state + .with_service(|service| service.fail_ai_run(ai_run_id, "Generation cancelled.")) + .ok() + }); let status = jobs.mark_cancelled(job_id).unwrap_or_else(|_| { fallback_status( job_id, diff --git a/src-tauri/src/commands/ai/job_runner.rs b/src-tauri/src/commands/ai/job_runner.rs index d6991f2..817776d 100644 --- a/src-tauri/src/commands/ai/job_runner.rs +++ b/src-tauri/src/commands/ai/job_runner.rs @@ -94,6 +94,10 @@ fn run_ai_action_job( let jobs = app.state::(); let state = app.state::(); let _ = send_progress(&events, &jobs, &job_id, "Preparing prompt"); + if control.is_cancelled() { + finish_cancelled_job(&events, &jobs, &state, &job_id, &request, None); + return; + } let prepare_started = Instant::now(); let prepared = @@ -101,7 +105,12 @@ fn run_ai_action_job( Ok(prepared) => prepared, Err(error) => { let error = error.message; - let status = jobs.fail(&job_id, &error).unwrap_or_else(|_| { + let failed_status = control.run_if_not_cancelled(|| jobs.fail(&job_id, &error)); + let Ok(Some(failed_status)) = failed_status else { + finish_cancelled_job(&events, &jobs, &state, &job_id, &request, None); + return; + }; + let status = failed_status.unwrap_or_else(|_| { fallback_status(&job_id, &request, GenerationJobState::Failed, &error) }); send_event( @@ -125,13 +134,27 @@ fn run_ai_action_job( prepare_started.elapsed().as_millis() ); - let running_status = match jobs.mark_running( - &job_id, - prepared.ai_run.id.clone(), - "Provider process starting", - ) { - Ok(status) => status, - Err(error) => { + let running_status = control.run_if_not_cancelled(|| { + jobs.mark_running( + &job_id, + prepared.ai_run.id.clone(), + "Provider process starting", + ) + }); + let running_status = match running_status { + Ok(Some(Ok(status))) => status, + Ok(None) => { + finish_cancelled_job( + &events, + &jobs, + &state, + &job_id, + &request, + Some(&prepared.ai_run.id), + ); + return; + } + Ok(Some(Err(error))) => { let error = error.to_string(); send_event( &events, @@ -144,6 +167,18 @@ fn run_ai_action_job( ); return; } + Err(error) => { + send_event( + &events, + GenerationJobEvent::Failed { + job_id: job_id.clone(), + status: fallback_status(&job_id, &request, GenerationJobState::Failed, &error), + error_message: error, + ai_run: Some(prepared.ai_run), + }, + ); + return; + } }; send_event( &events, @@ -162,7 +197,7 @@ fn run_ai_action_job( &state, &job_id, &request, - &prepared.ai_run.id, + Some(&prepared.ai_run.id), ); return; } @@ -183,6 +218,7 @@ fn run_ai_action_job( .as_ref() .map(|output| output.cancelled) .unwrap_or_else(|error| error == "Generation cancelled.") + || control.is_cancelled() { finish_cancelled_job( &events, @@ -190,16 +226,55 @@ fn run_ai_action_job( &state, &job_id, &request, - &prepared.ai_run.id, + Some(&prepared.ai_run.id), ); return; } - let result = state - .with_service(|service| finish_ai_action_generation(service, &request, prepared, output)); + let ai_run_id = prepared.ai_run.id.clone(); + let finalization = control.run_if_not_cancelled(|| { + let result = state.with_service(|service| { + finish_ai_action_generation(service, &request, prepared, output) + }); + let status = match &result { + Ok(result) if result.ai_run.error_message.is_none() => jobs.complete(&job_id), + Ok(result) => jobs.fail( + &job_id, + result + .ai_run + .error_message + .as_deref() + .unwrap_or("Generation failed"), + ), + Err(error) => jobs.fail(&job_id, &error.message), + }; + (result, status) + }); + let (result, terminal_status) = match finalization { + Ok(Some(finalization)) => finalization, + Ok(None) => { + finish_cancelled_job(&events, &jobs, &state, &job_id, &request, Some(&ai_run_id)); + return; + } + Err(error) => { + let status = jobs.fail(&job_id, &error).unwrap_or_else(|_| { + fallback_status(&job_id, &request, GenerationJobState::Failed, &error) + }); + send_event( + &events, + GenerationJobEvent::Failed { + job_id, + status, + error_message: error, + ai_run: None, + }, + ); + return; + } + }; match result { Ok(result) if result.ai_run.error_message.is_none() => { - let status = jobs.complete(&job_id).unwrap_or_else(|_| { + let status = terminal_status.unwrap_or_else(|_| { fallback_status(&job_id, &request, GenerationJobState::Completed, "") }); send_event( @@ -217,7 +292,7 @@ fn run_ai_action_job( .error_message .clone() .unwrap_or_else(|| "Generation failed".to_string()); - let status = jobs.fail(&job_id, &error_message).unwrap_or_else(|_| { + let status = terminal_status.unwrap_or_else(|_| { fallback_status( &job_id, &request, @@ -237,7 +312,7 @@ fn run_ai_action_job( } Err(error) => { let error = error.message; - let status = jobs.fail(&job_id, &error).unwrap_or_else(|_| { + let status = terminal_status.unwrap_or_else(|_| { fallback_status(&job_id, &request, GenerationJobState::Failed, &error) }); send_event( diff --git a/src-tauri/src/commands/ai/provider_execution.rs b/src-tauri/src/commands/ai/provider_execution.rs index 983abf7..6385f5e 100644 --- a/src-tauri/src/commands/ai/provider_execution.rs +++ b/src-tauri/src/commands/ai/provider_execution.rs @@ -15,7 +15,7 @@ use super::{ streaming_exec::run_generation_command_streaming, }; use crate::{ - commands::providers::provider_readiness, + commands::providers::provider_readiness_for_job, jobs::{JobControl, JobStore}, }; @@ -34,7 +34,13 @@ pub(super) fn execute_provider_generation_streaming( events: &Channel, control: &JobControl, ) -> Result { - let readiness = provider_readiness(provider); + if control.is_cancelled() { + return Err("Generation cancelled.".to_string()); + } + let readiness = provider_readiness_for_job(provider, control); + if control.is_cancelled() { + return Err("Generation cancelled.".to_string()); + } if !readiness.descriptor.status.is_ready() { return Err(readiness.descriptor.reason); } diff --git a/src-tauri/src/commands/ai/streaming_exec.rs b/src-tauri/src/commands/ai/streaming_exec.rs index f531364..30baebf 100644 --- a/src-tauri/src/commands/ai/streaming_exec.rs +++ b/src-tauri/src/commands/ai/streaming_exec.rs @@ -11,6 +11,7 @@ use std::{ collections::VecDeque, io::{BufRead, BufReader, Read, Write}, + path::PathBuf, process::{Child, Command, ExitStatus, Stdio}, sync::{ Arc, @@ -64,6 +65,7 @@ pub(super) fn run_generation_command_streaming( pub(super) struct ProcessProviderExecutor<'a> { control: &'a JobControl, watchdog_timeout: Duration, + provider_cwd_parent: Option, } impl<'a> ProcessProviderExecutor<'a> { @@ -71,6 +73,7 @@ impl<'a> ProcessProviderExecutor<'a> { Self { control, watchdog_timeout: GENERATION_WATCHDOG_TIMEOUT, + provider_cwd_parent: None, } } @@ -79,8 +82,26 @@ impl<'a> ProcessProviderExecutor<'a> { Self { control, watchdog_timeout, + provider_cwd_parent: None, } } + + #[cfg(test)] + fn with_provider_cwd_parent(control: &'a JobControl, parent: PathBuf) -> Self { + Self { + control, + watchdog_timeout: GENERATION_WATCHDOG_TIMEOUT, + provider_cwd_parent: Some(parent), + } + } + + fn provider_cwd(&self) -> Result { + match self.provider_cwd_parent.as_deref() { + Some(parent) => NeutralProviderCwd::new_in(parent), + None => NeutralProviderCwd::new(), + } + .map_err(|error| error.to_string()) + } } impl ProviderExecutor for ProcessProviderExecutor<'_> { @@ -113,7 +134,10 @@ impl ProviderExecutor for ProcessProviderExecutor<'_> { // Owned for the whole call; its directory is removed when this drops // at function exit (every return path, including `?` and panics). - let provider_cwd = NeutralProviderCwd::new(); + let provider_cwd = self.provider_cwd()?; + if control.is_cancelled() { + return Ok(ProviderExecution::cancelled()); + } let mut process = Command::new(&command.program); process diff --git a/src-tauri/src/commands/ai/streaming_exec/tests.rs b/src-tauri/src/commands/ai/streaming_exec/tests.rs index c572570..18d7343 100644 --- a/src-tauri/src/commands/ai/streaming_exec/tests.rs +++ b/src-tauri/src/commands/ai/streaming_exec/tests.rs @@ -131,7 +131,12 @@ fn reported_provider_cwd() -> PathBuf { match output { Ok(output) => { assert!(output.success(), "expected success, got {output:?}"); - return PathBuf::from(output.response_text().trim()); + return PathBuf::from( + output + .response_text() + .expect("plain provider output is compatible") + .trim(), + ); } Err(error) if error.contains("Text file busy") && attempt < 5 => { thread::sleep(Duration::from_millis(50)); @@ -175,9 +180,6 @@ fn provider_process_runs_in_a_neutral_working_directory() { #[test] fn each_generation_gets_a_fresh_provider_cwd_that_is_cleaned_up() { - // A single shared, persistent cwd would re-introduce contamination if any - // run ever wrote a CLAUDE.md there, and would let concurrent runs collide. - // Each generation must get its own directory, removed after it completes. let first = reported_provider_cwd(); let second = reported_provider_cwd(); @@ -195,6 +197,34 @@ fn each_generation_gets_a_fresh_provider_cwd_that_is_cleaned_up() { ); } +#[test] +fn private_provider_cwd_failure_prevents_generation_spawn() { + let marker = std::env::temp_dir().join(format!( + "qa-scribe-provider-spawn-marker-{}", + uuid::Uuid::new_v4().simple() + )); + let script = format!("#!/bin/sh\ntouch '{}'\n", marker.display()); + let cli = FakeCli::new("fake-must-not-run", &script); + let blocked_parent = cli.dir.join("blocked-parent"); + fs::write(&blocked_parent, b"not a directory").expect("blocking file should create"); + let command = cli.command("prompt".to_string(), GenerationOutputFormat::PlainText); + let control = JobControl::default(); + let executor = ProcessProviderExecutor::with_provider_cwd_parent(&control, blocked_parent); + + let error = run_streaming_generation(&executor, &command, |_| {}) + .expect_err("generation must fail before spawning without a private cwd"); + + assert!( + error.contains("Could not create a private working directory for the provider"), + "unexpected error: {error}" + ); + assert!(error.contains("provider was not started")); + assert!( + !marker.exists(), + "provider subprocess must not have spawned" + ); +} + #[test] fn normal_completion_accumulates_events() { let cli = FakeCli::new( @@ -213,7 +243,12 @@ fn normal_completion_accumulates_events() { .expect("streaming completes"); assert!(output.success(), "expected success, got {output:?}"); - assert_eq!(output.response_text(), "Hello world"); + assert_eq!( + output + .response_text() + .expect("Codex JSONL contains assistant text"), + "Hello world" + ); } #[test] @@ -376,7 +411,12 @@ fn large_stderr_before_reading_stdin_does_not_deadlock() { .expect("streaming completes without deadlock"); assert!(output.success(), "expected success, got {output:?}"); - assert_eq!(output.response_text(), "done"); + assert_eq!( + output + .response_text() + .expect("Codex JSONL contains assistant text"), + "done" + ); assert_eq!(output.stderr.len(), MAX_PROVIDER_STDERR_BYTES); assert!( output.stderr.ends_with(b"final actionable diagnostic"), diff --git a/src-tauri/src/commands/error.rs b/src-tauri/src/commands/error.rs index 91dbcf7..5ff083d 100644 --- a/src-tauri/src/commands/error.rs +++ b/src-tauri/src/commands/error.rs @@ -67,7 +67,9 @@ impl From for CommandError { match error { JobStoreError::Capacity { .. } => CommandError::validation(message), JobStoreError::NotFound { .. } => CommandError::not_found(message), - JobStoreError::Internal(_) => CommandError::internal(message), + JobStoreError::InvalidTransition { .. } | JobStoreError::Internal(_) => { + CommandError::internal(message) + } } } } @@ -140,6 +142,18 @@ mod tests { assert_eq!(missing.message, "Generation job job-1 was not found."); } + #[test] + fn illegal_job_transition_maps_to_internal_error() { + let error = CommandError::from(JobStoreError::InvalidTransition { + job_id: "job-1".to_string(), + from: crate::jobs::GenerationJobState::Cancelling, + to: crate::jobs::GenerationJobState::Completed, + }); + + assert_eq!(error.kind, CommandErrorKind::Internal); + assert!(error.message.contains("cannot transition")); + } + #[test] fn serializes_kind_as_camel_case_over_the_boundary() { let error = CommandError::not_found("not found: session-1"); diff --git a/src-tauri/src/commands/files.rs b/src-tauri/src/commands/files.rs index 5da30d7..786a6bc 100644 --- a/src-tauri/src/commands/files.rs +++ b/src-tauri/src/commands/files.rs @@ -3,7 +3,8 @@ use std::io::Cursor; use base64::{Engine, engine::general_purpose::STANDARD}; use qa_scribe_core::{ attachments::{ - attachment_file_bytes, attachment_preview_data_url, import_clipboard_screenshot_data_url, + attachment_file_bytes, attachment_preview_data_url, delete_attachment_with_file, + import_clipboard_screenshot_data_url, }, domain::Attachment, }; @@ -36,6 +37,17 @@ pub fn import_clipboard_screenshot( }) } +#[tauri::command] +#[specta::specta] +pub fn delete_attachment( + state: State<'_, AppState>, + attachment_id: String, +) -> Result { + let app_data_dir = state.app_data_dir().clone(); + state + .with_service(|service| delete_attachment_with_file(service, &app_data_dir, &attachment_id)) +} + #[tauri::command] #[specta::specta] pub async fn read_clipboard_image_data_url(app: AppHandle) -> Result, CommandError> { diff --git a/src-tauri/src/commands/providers.rs b/src-tauri/src/commands/providers.rs index 93661e9..c485381 100644 --- a/src-tauri/src/commands/providers.rs +++ b/src-tauri/src/commands/providers.rs @@ -29,6 +29,7 @@ use detection::{detect_capability, provider_readiness_with_runners}; use probe::ProbeRunner; use probe::{DetectionMode, SystemProbeRunner}; +use crate::jobs::JobControl; use crate::provider_command::{ProviderPathMode, invalidate_provider_path_cache}; pub fn cancel_active_provider_discovery() { @@ -53,11 +54,11 @@ pub async fn refresh_provider_status() -> ProviderStatus { .expect("provider discovery worker should complete") } -pub fn provider_readiness(provider: AiProvider) -> ProviderReadiness { +pub fn provider_readiness_for_job(provider: AiProvider, control: &JobControl) -> ProviderReadiness { provider_readiness_with_runners( provider, - &SystemProbeRunner::new(ProviderPathMode::Fast), - &SystemProbeRunner::new(ProviderPathMode::Deep), + &SystemProbeRunner::for_job(ProviderPathMode::Fast, control), + &SystemProbeRunner::for_job(ProviderPathMode::Deep, control), ) } diff --git a/src-tauri/src/commands/providers/detection.rs b/src-tauri/src/commands/providers/detection.rs index 2beb97e..b53ea20 100644 --- a/src-tauri/src/commands/providers/detection.rs +++ b/src-tauri/src/commands/providers/detection.rs @@ -178,6 +178,14 @@ fn detect_cli_provider( false, ); } + if auth.scope_error.is_some() { + return not_installed_or_error( + capability, + auth, + descriptor.auth_required_reason, + executable_path, + ); + } descriptor_readiness( capability, @@ -238,6 +246,14 @@ fn detect_copilot( } let help = runner.run("copilot", &["--help"]); + if help.scope_error.is_some() { + return not_installed_or_error( + capability, + help, + "Could not inspect GitHub Copilot CLI readiness.", + executable_path, + ); + } if !copilot_supports_prompt_mode(&help) { return descriptor_readiness( capability, @@ -327,7 +343,9 @@ fn not_installed_or_error( } else { ProviderState::Error }; - let reason = install_message.to_string(); + let reason = probe + .scope_error + .unwrap_or_else(|| install_message.to_string()); descriptor_readiness( capability, @@ -417,7 +435,9 @@ fn sanitize_cli_version(version: Option) -> Option { #[cfg(test)] mod privacy_tests { - use super::sanitize_cli_version; + use qa_scribe_core::{ai::provider_capabilities, domain::AiProvider}; + + use super::{CommandProbe, ProviderState, not_installed_or_error, sanitize_cli_version}; #[test] fn cli_versions_are_reduced_to_a_bounded_version_token() { @@ -429,4 +449,23 @@ mod privacy_tests { ); assert_eq!(sanitize_cli_version(Some("token=secret".to_string())), None); } + + #[test] + fn private_scope_failure_is_the_actionable_readiness_reason() { + let capability = provider_capabilities() + .into_iter() + .find(|capability| capability.id == AiProvider::CodexCli) + .expect("Codex capability exists"); + let message = "Could not create a private working directory for the provider. The provider was not started."; + + let readiness = not_installed_or_error( + capability, + CommandProbe::scope_unavailable(message.to_string()), + "Install Codex CLI.", + None, + ); + + assert_eq!(readiness.descriptor.status, ProviderState::Error); + assert_eq!(readiness.descriptor.reason, message); + } } diff --git a/src-tauri/src/commands/providers/models/tests.rs b/src-tauri/src/commands/providers/models/tests.rs index af89d1d..9db4f9b 100644 --- a/src-tauri/src/commands/providers/models/tests.rs +++ b/src-tauri/src/commands/providers/models/tests.rs @@ -168,6 +168,7 @@ fn structured_failure_category_survives_a_help_fallback() { stdout: "--model use 'gpt-fallback'".to_string(), stderr: String::new(), not_found: false, + scope_error: None, }, }; diff --git a/src-tauri/src/commands/providers/probe.rs b/src-tauri/src/commands/providers/probe.rs index bedabfc..08a5eee 100644 --- a/src-tauri/src/commands/providers/probe.rs +++ b/src-tauri/src/commands/providers/probe.rs @@ -1,11 +1,11 @@ use std::{ collections::HashMap, - io::{BufRead, BufReader, ErrorKind, Read, Write}, - path::PathBuf, + io::{BufRead, BufReader, Read, Write}, + path::{Path, PathBuf}, process::{Command, Stdio}, - sync::{OnceLock, mpsc}, + sync::mpsc, thread, - time::{Duration, Instant}, + time::Instant, }; use qa_scribe_core::domain::AiProvider; @@ -14,9 +14,7 @@ use serde_json::{Value, json}; use crate::{ commands::providers::{ProviderDiscoveryError, ProviderDiscoveryErrorCode}, process_io::{configure_process_group, kill_child_group}, - provider_command::{ - NeutralProviderCwd, ProviderPathMode, apply_provider_path, provider_executable_path, - }, + provider_command::{NeutralProviderCwd, ProviderPathMode, apply_provider_path}, }; mod cancel; @@ -24,16 +22,17 @@ mod claude; mod command; mod copilot; mod identity; +mod system; pub(super) use cancel::cancel_all_provider_discovery; use cancel::{CANCELLATION_POLL_INTERVAL, DiscoveryCancellation}; +pub(super) use command::MAX_PROVIDER_OUTPUT_BYTES; #[cfg(test)] pub(super) use command::ProbeOutputFiles; -pub(super) use command::{MAX_PROVIDER_OUTPUT_BYTES, run_command_with_timeout}; -use identity::{discovery_cache_fingerprint, provider_executable}; +#[cfg(test)] +pub(super) use command::run_command_with_timeout; +pub(super) use system::SystemProbeRunner; -const PROVIDER_PROBE_TIMEOUT: Duration = Duration::from_secs(4); -const PROVIDER_TRANSACTION_TIMEOUT: Duration = Duration::from_secs(12); pub(super) const MAX_PROVIDER_MODELS: usize = 1_000; #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -57,6 +56,7 @@ pub(super) struct CommandProbe { pub(super) stdout: String, pub(super) stderr: String, pub(super) not_found: bool, + pub(super) scope_error: Option, } pub(super) trait ProbeRunner { @@ -76,129 +76,6 @@ pub(super) trait ProbeRunner { } } -pub(super) struct SystemProbeRunner { - path_mode: ProviderPathMode, - deadline: Instant, - codex_defaults: OnceLock, - claude_catalog: OnceLock, - copilot_catalog: OnceLock, -} - -impl SystemProbeRunner { - pub(super) fn new(path_mode: ProviderPathMode) -> Self { - Self { - path_mode, - deadline: Instant::now() + PROVIDER_TRANSACTION_TIMEOUT, - codex_defaults: OnceLock::new(), - claude_catalog: OnceLock::new(), - copilot_catalog: OnceLock::new(), - } - } - - fn remaining(&self) -> Duration { - self.deadline - .saturating_duration_since(Instant::now()) - .min(PROVIDER_PROBE_TIMEOUT) - } -} - -impl ProbeRunner for SystemProbeRunner { - fn executable_path(&self, program: &str) -> Option { - provider_executable_path(program, self.path_mode) - } - - fn run(&self, program: &str, args: &[&str]) -> CommandProbe { - let executable = provider_executable_path(program, self.path_mode) - .unwrap_or_else(|| PathBuf::from(program)); - let provider_cwd = NeutralProviderCwd::new(); - let mut command = Command::new(executable); - command.args(args); - command.current_dir(provider_cwd.path()); - apply_provider_path(&mut command); - if program == "copilot" { - command.env("COPILOT_AUTO_UPDATE", "false"); - } - - match run_command_with_timeout(command, self.remaining()) { - Ok(output) => CommandProbe::from_output(output), - Err(error) => CommandProbe { - success: false, - stdout: String::new(), - stderr: error.to_string(), - not_found: error.kind() == ErrorKind::NotFound, - }, - } - } - - fn cache_fingerprint(&self, provider: AiProvider) -> u64 { - discovery_cache_fingerprint( - provider, - provider_executable_path(provider_executable(provider), self.path_mode).as_deref(), - ) - } - - fn codex_app_server_defaults(&self) -> CodexDefaultsProbe { - if self.path_mode != ProviderPathMode::Deep { - return CodexDefaultsProbe::NotAttempted; - } - self.codex_defaults - .get_or_init(|| match read_codex_app_server_defaults(self.deadline) { - Ok(defaults) => CodexDefaultsProbe::Success(defaults), - Err(error) => CodexDefaultsProbe::Failed(error), - }) - .clone() - } - - fn claude_structured_catalog(&self) -> StructuredCatalogProbe { - if self.path_mode != ProviderPathMode::Deep { - return StructuredCatalogProbe::NotAttempted; - } - self.claude_catalog - .get_or_init(|| { - let Some(executable) = provider_executable_path("claude", self.path_mode) else { - return StructuredCatalogProbe::NotAttempted; - }; - let version = self.run("claude", &["--version"]); - if !version.success || !claude::version_is_supported(&version.stdout) { - return StructuredCatalogProbe::Failed(ProviderDiscoveryError { - code: ProviderDiscoveryErrorCode::Unsupported, - message: "This Claude Code version does not support safe model discovery." - .to_string(), - retryable: false, - }); - } - match claude::discover(&executable, self.deadline) { - Ok(result) => StructuredCatalogProbe::Success(StructuredCatalog { - models: result.models, - cli_version: Some(version.stdout), - }), - Err(error) => StructuredCatalogProbe::Failed(error), - } - }) - .clone() - } - - fn copilot_structured_catalog(&self) -> StructuredCatalogProbe { - if self.path_mode != ProviderPathMode::Deep { - return StructuredCatalogProbe::NotAttempted; - } - self.copilot_catalog - .get_or_init(|| { - let Some(executable) = provider_executable_path("copilot", self.path_mode) else { - return StructuredCatalogProbe::NotAttempted; - }; - match copilot::discover(&executable, self.deadline) { - Ok(result) => StructuredCatalogProbe::Success(StructuredCatalog { - models: result.models, - cli_version: result.cli_version, - }), - Err(error) => StructuredCatalogProbe::Failed(error), - } - }) - .clone() - } -} - #[derive(Clone, Debug, Eq, PartialEq)] pub(super) struct CodexAppServerDefaults { pub(super) config: Value, @@ -227,9 +104,15 @@ pub(super) enum StructuredCatalogProbe { fn read_codex_app_server_defaults( deadline: Instant, + cancellation: DiscoveryCancellation, + provider_cwd_parent: Option<&Path>, ) -> Result { - DiscoveryCancellation::capture().check("Codex")?; - let provider_cwd = NeutralProviderCwd::new(); + cancellation.check("Codex")?; + let provider_cwd = match provider_cwd_parent { + Some(parent) => NeutralProviderCwd::new_in(parent), + None => NeutralProviderCwd::new(), + } + .map_err(|error| discovery_error(ProviderDiscoveryErrorCode::SpawnFailed, error))?; let mut command = Command::new("codex"); command.arg("app-server").current_dir(provider_cwd.path()); apply_provider_path(&mut command); @@ -260,7 +143,7 @@ fn read_codex_app_server_defaults( let reader = thread::spawn(move || read_bounded_json_lines(stdout, sender)); let result = (|| { - let mut responses = ResponseRouter::new(&receiver); + let mut responses = ResponseRouter::new_with_cancellation(&receiver, cancellation); send_request( &mut stdin, &json!({ @@ -321,11 +204,19 @@ struct ResponseRouter<'a> { } impl<'a> ResponseRouter<'a> { + #[cfg(test)] fn new(receiver: &'a mpsc::Receiver) -> Self { + Self::new_with_cancellation(receiver, DiscoveryCancellation::capture()) + } + + fn new_with_cancellation( + receiver: &'a mpsc::Receiver, + cancellation: DiscoveryCancellation, + ) -> Self { Self { receiver, pending: HashMap::new(), - cancellation: DiscoveryCancellation::capture(), + cancellation, } } @@ -496,5 +387,7 @@ fn discovery_error( } } +#[cfg(all(test, unix))] +mod lifecycle_tests; #[cfg(test)] mod protocol_tests; diff --git a/src-tauri/src/commands/providers/probe/cancel.rs b/src-tauri/src/commands/providers/probe/cancel.rs index 7aa61c0..7d9a85a 100644 --- a/src-tauri/src/commands/providers/probe/cancel.rs +++ b/src-tauri/src/commands/providers/probe/cancel.rs @@ -4,29 +4,43 @@ use std::{ }; use crate::commands::providers::{ProviderDiscoveryError, ProviderDiscoveryErrorCode}; +use crate::jobs::JobControl; pub(super) const CANCELLATION_POLL_INTERVAL: Duration = Duration::from_millis(25); static DISCOVERY_CANCELLATION_EPOCH: AtomicU64 = AtomicU64::new(0); static PROVIDER_DISCOVERY_SHUTTING_DOWN: AtomicBool = AtomicBool::new(false); -#[derive(Clone, Copy)] +#[derive(Clone)] pub(super) struct DiscoveryCancellation { epoch: u64, + job_control: Option, } impl DiscoveryCancellation { pub(super) fn capture() -> Self { Self { epoch: DISCOVERY_CANCELLATION_EPOCH.load(Ordering::Acquire), + job_control: None, } } - pub(super) fn is_cancelled(self) -> bool { + pub(super) fn for_job(job_control: &JobControl) -> Self { + Self { + epoch: DISCOVERY_CANCELLATION_EPOCH.load(Ordering::Acquire), + job_control: Some(job_control.clone()), + } + } + + pub(super) fn is_cancelled(&self) -> bool { PROVIDER_DISCOVERY_SHUTTING_DOWN.load(Ordering::Acquire) || self.epoch != DISCOVERY_CANCELLATION_EPOCH.load(Ordering::Acquire) + || self + .job_control + .as_ref() + .is_some_and(JobControl::is_cancelled) } - pub(super) fn check(self, provider_label: &str) -> Result<(), ProviderDiscoveryError> { + pub(super) fn check(&self, provider_label: &str) -> Result<(), ProviderDiscoveryError> { if self.is_cancelled() { Err(ProviderDiscoveryError { code: ProviderDiscoveryErrorCode::Cancelled, @@ -53,6 +67,7 @@ mod tests { let current = DISCOVERY_CANCELLATION_EPOCH.load(Ordering::Acquire); let cancellation = DiscoveryCancellation { epoch: current.wrapping_sub(1), + job_control: None, }; let error = cancellation.check("Provider").unwrap_err(); diff --git a/src-tauri/src/commands/providers/probe/claude/mod.rs b/src-tauri/src/commands/providers/probe/claude/mod.rs index 416c8b5..5db0a5a 100644 --- a/src-tauri/src/commands/providers/probe/claude/mod.rs +++ b/src-tauri/src/commands/providers/probe/claude/mod.rs @@ -60,15 +60,32 @@ pub(super) struct ClaudeCatalogResult { /// The child receives exactly one Agent SDK `initialize` control frame. Its /// account payload and all non-model events are ignored; returned model values /// are rebuilt from a small allowlist before leaving this module. +#[cfg(test)] pub(super) fn discover( executable: &Path, deadline: Instant, ) -> Result { - let cancellation = DiscoveryCancellation::capture(); + discover_with_cancellation(executable, deadline, DiscoveryCancellation::capture(), None) +} + +pub(super) fn discover_with_cancellation( + executable: &Path, + deadline: Instant, + cancellation: DiscoveryCancellation, + provider_cwd_parent: Option<&Path>, +) -> Result { cancellation.check("Claude Code")?; ensure_before_deadline(deadline)?; - let provider_cwd = NeutralProviderCwd::new(); + let provider_cwd = match provider_cwd_parent { + Some(parent) => NeutralProviderCwd::new_in(parent), + None => NeutralProviderCwd::new(), + } + .map_err(|error| ProviderDiscoveryError { + code: ProviderDiscoveryErrorCode::SpawnFailed, + message: error.to_string(), + retryable: true, + })?; let mut command = Command::new(executable); command .args(CLAUDE_DISCOVERY_ARGS) diff --git a/src-tauri/src/commands/providers/probe/command.rs b/src-tauri/src/commands/providers/probe/command.rs index 886e039..2f50aa1 100644 --- a/src-tauri/src/commands/providers/probe/command.rs +++ b/src-tauri/src/commands/providers/probe/command.rs @@ -14,6 +14,7 @@ use super::cancel::DiscoveryCancellation; pub(in crate::commands::providers) const MAX_PROVIDER_OUTPUT_BYTES: u64 = 1024 * 1024; +#[cfg(test)] pub(in crate::commands::providers) fn run_command_with_timeout( command: Command, timeout: Duration, @@ -22,10 +23,27 @@ pub(in crate::commands::providers) fn run_command_with_timeout( run_command_with_cancellation_check(command, timeout, || cancellation.is_cancelled()) } +pub(super) fn run_command_with_cancellation( + command: Command, + timeout: Duration, + cancellation: &DiscoveryCancellation, +) -> std::io::Result { + run_command_with_cancellation_check(command, timeout, || cancellation.is_cancelled()) +} + fn run_command_with_cancellation_check( + command: Command, + timeout: Duration, + is_cancelled: impl Fn() -> bool, +) -> std::io::Result { + run_command_with_output_files(command, timeout, is_cancelled, ProbeOutputFiles::new()) +} + +fn run_command_with_output_files( mut command: Command, timeout: Duration, is_cancelled: impl Fn() -> bool, + output_files: ProbeOutputFiles, ) -> std::io::Result { if is_cancelled() { return Err(std::io::Error::new( @@ -33,7 +51,6 @@ fn run_command_with_cancellation_check( "provider probe was cancelled", )); } - let output_files = ProbeOutputFiles::new(); let (stdout, stderr) = output_files.create()?; configure_process_group(&mut command); let mut child = command @@ -167,6 +184,7 @@ impl CommandProbe { stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(), stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), not_found: false, + scope_error: None, } } @@ -176,6 +194,17 @@ impl CommandProbe { stdout: String::new(), stderr: "command not found".to_string(), not_found: true, + scope_error: None, + } + } + + pub(in crate::commands::providers) fn scope_unavailable(message: String) -> Self { + Self { + success: false, + stdout: String::new(), + stderr: message.clone(), + not_found: false, + scope_error: Some(message), } } } @@ -203,6 +232,31 @@ mod tests { assert!(!output_files.exceeds_limit(1_200)); } + #[test] + fn spawn_failure_removes_only_its_owned_output_directory() { + let output_files = ProbeOutputFiles::new(); + let owned_directory = output_files.directory_path.clone(); + let ambient_output_files = ProbeOutputFiles::new(); + let ambient_directory = ambient_output_files.directory_path.clone(); + drop( + ambient_output_files + .create() + .expect("ambient probe files should be created"), + ); + + let error = run_command_with_output_files( + Command::new("qa-scribe-provider-probe-command-that-does-not-exist"), + Duration::from_millis(10), + || false, + output_files, + ) + .expect_err("missing command should fail to spawn"); + + assert_eq!(error.kind(), ErrorKind::NotFound); + assert!(!owned_directory.exists()); + assert!(ambient_directory.exists()); + } + #[cfg(unix)] #[test] fn probe_output_directory_and_files_are_private() { diff --git a/src-tauri/src/commands/providers/probe/copilot/mod.rs b/src-tauri/src/commands/providers/probe/copilot/mod.rs index 432c587..bd95d72 100644 --- a/src-tauri/src/commands/providers/probe/copilot/mod.rs +++ b/src-tauri/src/commands/providers/probe/copilot/mod.rs @@ -56,14 +56,37 @@ pub(super) struct CopilotCatalogResult { /// client. It never creates a session or sends prompt-bearing data, and every /// provider value is rebuilt from an explicit allowlist before it leaves this /// module. +#[cfg(test)] pub(super) fn discover( executable: &Path, deadline: Instant, ) -> Result { - super::cancel::DiscoveryCancellation::capture().check("GitHub Copilot")?; + discover_with_cancellation( + executable, + deadline, + super::cancel::DiscoveryCancellation::capture(), + None, + ) +} + +pub(super) fn discover_with_cancellation( + executable: &Path, + deadline: Instant, + cancellation: super::cancel::DiscoveryCancellation, + provider_cwd_parent: Option<&Path>, +) -> Result { + cancellation.check("GitHub Copilot")?; ensure_before_deadline(deadline)?; - let provider_cwd = NeutralProviderCwd::new(); + let provider_cwd = match provider_cwd_parent { + Some(parent) => NeutralProviderCwd::new_in(parent), + None => NeutralProviderCwd::new(), + } + .map_err(|error| ProviderDiscoveryError { + code: ProviderDiscoveryErrorCode::SpawnFailed, + message: error.to_string(), + retryable: true, + })?; let mut command = Command::new(executable); command .args(["--server", "--stdio", "--no-auto-update"]) @@ -100,7 +123,7 @@ pub(super) fn discover( let reader = thread::spawn(move || read_stdout_frames(stdout, sender)); let result = (|| { - let mut responses = ResponseRouter::new(&receiver); + let mut responses = ResponseRouter::new_with_cancellation(&receiver, cancellation); ensure_before_deadline(deadline)?; send_request(&mut stdin, CONNECT_REQUEST_ID, DISCOVERY_METHODS[0])?; diff --git a/src-tauri/src/commands/providers/probe/copilot/protocol.rs b/src-tauri/src/commands/providers/probe/copilot/protocol.rs index 16d1cff..5bbab06 100644 --- a/src-tauri/src/commands/providers/probe/copilot/protocol.rs +++ b/src-tauri/src/commands/providers/probe/copilot/protocol.rs @@ -29,11 +29,14 @@ pub(super) struct ResponseRouter<'a> { } impl<'a> ResponseRouter<'a> { - pub(super) fn new(receiver: &'a mpsc::Receiver) -> Self { + pub(super) fn new_with_cancellation( + receiver: &'a mpsc::Receiver, + cancellation: DiscoveryCancellation, + ) -> Self { Self { receiver, pending: HashMap::new(), - cancellation: DiscoveryCancellation::capture(), + cancellation, } } diff --git a/src-tauri/src/commands/providers/probe/lifecycle_tests.rs b/src-tauri/src/commands/providers/probe/lifecycle_tests.rs new file mode 100644 index 0000000..942893f --- /dev/null +++ b/src-tauri/src/commands/providers/probe/lifecycle_tests.rs @@ -0,0 +1,117 @@ +use std::{ + fs, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + thread, + time::{Duration, Instant}, +}; + +use crate::{jobs::JobControl, provider_command::ProviderPathMode}; + +use super::{ProbeRunner, SystemProbeRunner}; + +struct FakeProbeCli { + directory: PathBuf, + executable: PathBuf, +} + +impl FakeProbeCli { + fn new(script: &str) -> Self { + let directory = std::env::temp_dir().join(format!( + "qa-scribe-readiness-test-{}", + uuid::Uuid::new_v4().simple() + )); + fs::create_dir(&directory).expect("fake probe directory should create"); + let executable = directory.join("provider-probe"); + fs::write(&executable, script).expect("fake probe executable should write"); + let mut permissions = fs::metadata(&executable).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&executable, permissions) + .expect("fake probe executable should be executable"); + Self { + directory, + executable, + } + } +} + +impl Drop for FakeProbeCli { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.directory); + } +} + +fn run(runner: &SystemProbeRunner, executable: &Path) -> super::CommandProbe { + runner.run(&executable.to_string_lossy(), &[]) +} + +#[test] +fn private_scope_failure_prevents_probe_spawn() { + let marker = std::env::temp_dir().join(format!( + "qa-scribe-probe-spawn-marker-{}", + uuid::Uuid::new_v4().simple() + )); + let cli = FakeProbeCli::new(&format!("#!/bin/sh\ntouch '{}'\n", marker.display())); + let blocked_parent = cli.directory.join("blocked-parent"); + fs::write(&blocked_parent, b"not a directory").expect("blocking file should create"); + let runner = + SystemProbeRunner::with_provider_cwd_parent(ProviderPathMode::Fast, blocked_parent); + + let probe = run(&runner, &cli.executable); + + assert!(!probe.success); + assert!( + probe + .scope_error + .as_deref() + .is_some_and(|error| error.contains("private working directory")) + ); + assert!(probe.stderr.contains("provider was not started")); + assert!(!marker.exists(), "probe subprocess must not have spawned"); +} + +#[test] +fn readiness_probe_observes_per_job_cancellation_and_reaps_child() { + let cli = FakeProbeCli::new("#!/bin/sh\nsleep 120\n"); + let control = JobControl::default(); + let cancel_control = control.clone(); + let runner = SystemProbeRunner::for_job(ProviderPathMode::Fast, &control); + let canceller = thread::spawn(move || { + thread::sleep(Duration::from_millis(100)); + cancel_control + .request_cancel() + .expect("job cancellation should be recorded"); + }); + let started = Instant::now(); + + let probe = run(&runner, &cli.executable); + canceller.join().unwrap(); + + assert!(!probe.success); + assert!(probe.stderr.contains("provider probe was cancelled")); + assert!( + started.elapsed() < Duration::from_secs(2), + "readiness cancellation should not wait for the probe timeout" + ); +} + +#[test] +fn successful_probe_cleans_its_owned_private_scope() { + let cli = FakeProbeCli::new("#!/bin/sh\npwd\n"); + let runner = SystemProbeRunner::new(ProviderPathMode::Fast); + + let probe = run(&runner, &cli.executable); + let observed_cwd = PathBuf::from(&probe.stdout); + + assert!(probe.success, "probe failed: {}", probe.stderr); + assert!( + observed_cwd + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("qa-scribe-provider-cwd-")) + ); + assert!( + !observed_cwd.exists(), + "probe-owned private scope should be removed after completion" + ); +} diff --git a/src-tauri/src/commands/providers/probe/system.rs b/src-tauri/src/commands/providers/probe/system.rs new file mode 100644 index 0000000..49903a2 --- /dev/null +++ b/src-tauri/src/commands/providers/probe/system.rs @@ -0,0 +1,210 @@ +use std::{ + io::ErrorKind, + path::PathBuf, + process::Command, + sync::OnceLock, + time::{Duration, Instant}, +}; + +use qa_scribe_core::domain::AiProvider; + +use crate::{ + commands::providers::{ProviderDiscoveryError, ProviderDiscoveryErrorCode}, + jobs::JobControl, + provider_command::{ + NeutralProviderCwd, ProviderPathMode, apply_provider_path, provider_executable_path, + }, +}; + +use super::{ + CodexDefaultsProbe, CommandProbe, ProbeRunner, StructuredCatalog, StructuredCatalogProbe, + cancel::DiscoveryCancellation, + claude, + command::run_command_with_cancellation, + copilot, + identity::{discovery_cache_fingerprint, provider_executable}, + read_codex_app_server_defaults, +}; + +const PROVIDER_PROBE_TIMEOUT: Duration = Duration::from_secs(4); +const PROVIDER_TRANSACTION_TIMEOUT: Duration = Duration::from_secs(12); + +pub(in crate::commands::providers) struct SystemProbeRunner { + path_mode: ProviderPathMode, + deadline: Instant, + codex_defaults: OnceLock, + claude_catalog: OnceLock, + copilot_catalog: OnceLock, + cancellation: DiscoveryCancellation, + provider_cwd_parent: Option, +} + +impl SystemProbeRunner { + pub(in crate::commands::providers) fn new(path_mode: ProviderPathMode) -> Self { + Self { + path_mode, + deadline: Instant::now() + PROVIDER_TRANSACTION_TIMEOUT, + codex_defaults: OnceLock::new(), + claude_catalog: OnceLock::new(), + copilot_catalog: OnceLock::new(), + cancellation: DiscoveryCancellation::capture(), + provider_cwd_parent: None, + } + } + + pub(in crate::commands::providers) fn for_job( + path_mode: ProviderPathMode, + control: &JobControl, + ) -> Self { + Self { + path_mode, + deadline: Instant::now() + PROVIDER_TRANSACTION_TIMEOUT, + codex_defaults: OnceLock::new(), + claude_catalog: OnceLock::new(), + copilot_catalog: OnceLock::new(), + cancellation: DiscoveryCancellation::for_job(control), + provider_cwd_parent: None, + } + } + + #[cfg(test)] + pub(in crate::commands::providers) fn with_provider_cwd_parent( + path_mode: ProviderPathMode, + parent: PathBuf, + ) -> Self { + Self { + provider_cwd_parent: Some(parent), + ..Self::new(path_mode) + } + } + + fn remaining(&self) -> Duration { + self.deadline + .saturating_duration_since(Instant::now()) + .min(PROVIDER_PROBE_TIMEOUT) + } + + fn provider_cwd(&self) -> std::io::Result { + match self.provider_cwd_parent.as_deref() { + Some(parent) => NeutralProviderCwd::new_in(parent), + None => NeutralProviderCwd::new(), + } + } +} + +impl ProbeRunner for SystemProbeRunner { + fn executable_path(&self, program: &str) -> Option { + provider_executable_path(program, self.path_mode) + } + + fn run(&self, program: &str, args: &[&str]) -> CommandProbe { + let executable = provider_executable_path(program, self.path_mode) + .unwrap_or_else(|| PathBuf::from(program)); + let provider_cwd = match self.provider_cwd() { + Ok(provider_cwd) => provider_cwd, + Err(error) => return CommandProbe::scope_unavailable(error.to_string()), + }; + let mut command = Command::new(executable); + command.args(args); + command.current_dir(provider_cwd.path()); + apply_provider_path(&mut command); + if program == "copilot" { + command.env("COPILOT_AUTO_UPDATE", "false"); + } + + match run_command_with_cancellation(command, self.remaining(), &self.cancellation) { + Ok(output) => CommandProbe::from_output(output), + Err(error) => CommandProbe { + success: false, + stdout: String::new(), + stderr: error.to_string(), + not_found: error.kind() == ErrorKind::NotFound, + scope_error: None, + }, + } + } + + fn cache_fingerprint(&self, provider: AiProvider) -> u64 { + discovery_cache_fingerprint( + provider, + provider_executable_path(provider_executable(provider), self.path_mode).as_deref(), + ) + } + + fn codex_app_server_defaults(&self) -> CodexDefaultsProbe { + if self.path_mode != ProviderPathMode::Deep { + return CodexDefaultsProbe::NotAttempted; + } + self.codex_defaults + .get_or_init(|| { + match read_codex_app_server_defaults( + self.deadline, + self.cancellation.clone(), + self.provider_cwd_parent.as_deref(), + ) { + Ok(defaults) => CodexDefaultsProbe::Success(defaults), + Err(error) => CodexDefaultsProbe::Failed(error), + } + }) + .clone() + } + + fn claude_structured_catalog(&self) -> StructuredCatalogProbe { + if self.path_mode != ProviderPathMode::Deep { + return StructuredCatalogProbe::NotAttempted; + } + self.claude_catalog + .get_or_init(|| { + let Some(executable) = provider_executable_path("claude", self.path_mode) else { + return StructuredCatalogProbe::NotAttempted; + }; + let version = self.run("claude", &["--version"]); + if !version.success || !claude::version_is_supported(&version.stdout) { + return StructuredCatalogProbe::Failed(ProviderDiscoveryError { + code: ProviderDiscoveryErrorCode::Unsupported, + message: "This Claude Code version does not support safe model discovery." + .to_string(), + retryable: false, + }); + } + match claude::discover_with_cancellation( + &executable, + self.deadline, + self.cancellation.clone(), + self.provider_cwd_parent.as_deref(), + ) { + Ok(result) => StructuredCatalogProbe::Success(StructuredCatalog { + models: result.models, + cli_version: Some(version.stdout), + }), + Err(error) => StructuredCatalogProbe::Failed(error), + } + }) + .clone() + } + + fn copilot_structured_catalog(&self) -> StructuredCatalogProbe { + if self.path_mode != ProviderPathMode::Deep { + return StructuredCatalogProbe::NotAttempted; + } + self.copilot_catalog + .get_or_init(|| { + let Some(executable) = provider_executable_path("copilot", self.path_mode) else { + return StructuredCatalogProbe::NotAttempted; + }; + match copilot::discover_with_cancellation( + &executable, + self.deadline, + self.cancellation.clone(), + self.provider_cwd_parent.as_deref(), + ) { + Ok(result) => StructuredCatalogProbe::Success(StructuredCatalog { + models: result.models, + cli_version: result.cli_version, + }), + Err(error) => StructuredCatalogProbe::Failed(error), + } + }) + .clone() + } +} diff --git a/src-tauri/src/commands/providers/tests/mod.rs b/src-tauri/src/commands/providers/tests/mod.rs index d549da6..c80386e 100644 --- a/src-tauri/src/commands/providers/tests/mod.rs +++ b/src-tauri/src/commands/providers/tests/mod.rs @@ -1,4 +1,4 @@ -use std::{collections::HashSet, fs, path::PathBuf, process::Command, sync::Mutex, time::Duration}; +use std::{fs, path::PathBuf, process::Command, time::Duration}; #[cfg(unix)] use std::thread; @@ -20,27 +20,9 @@ mod support; use support::{MockRunner, copilot_prompt_help}; -static PROVIDER_PROBE_TEMP_FILE_TEST_LOCK: Mutex<()> = Mutex::new(()); - -#[test] -fn provider_probe_cleans_temp_files_when_spawn_fails() { - let _probe_lock = lock_provider_probe_temp_file_tests(); - let before = provider_probe_temp_files(); - - let error = run_command_with_timeout( - Command::new("qa-scribe-provider-probe-command-that-does-not-exist"), - Duration::from_millis(10), - ) - .expect_err("missing command should fail to spawn"); - - assert_eq!(error.kind(), std::io::ErrorKind::NotFound); - assert_eq!(provider_probe_temp_files(), before); -} - #[cfg(unix)] #[test] fn provider_probe_timeout_kills_descendant_processes() { - let _probe_lock = lock_provider_probe_temp_file_tests(); let marker = std::env::temp_dir().join(format!( "qa-scribe-provider-probe-descendant-{}-{}", std::process::id(), @@ -69,7 +51,6 @@ fn provider_probe_timeout_kills_descendant_processes() { #[test] fn provider_probe_output_files_are_created_exclusively() { - let _probe_lock = lock_provider_probe_temp_file_tests(); let output_files = ProbeOutputFiles::new(); fs::create_dir( output_files @@ -105,26 +86,6 @@ fn generic_provider_probes_run_outside_the_repository() { ); } -fn lock_provider_probe_temp_file_tests() -> std::sync::MutexGuard<'static, ()> { - PROVIDER_PROBE_TEMP_FILE_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) -} - -fn provider_probe_temp_files() -> HashSet { - let prefix = format!("qa-scribe-provider-probe-{}-", std::process::id()); - fs::read_dir(std::env::temp_dir()) - .expect("temp dir should be readable") - .filter_map(Result::ok) - .map(|entry| entry.path()) - .filter(|path| { - path.file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.starts_with(&prefix)) - }) - .collect() -} - #[test] fn provider_status_is_local_and_reports_all_providers() { let runner = MockRunner::default() diff --git a/src-tauri/src/commands/providers/tests/support.rs b/src-tauri/src/commands/providers/tests/support.rs index 217b35d..b2e3a90 100644 --- a/src-tauri/src/commands/providers/tests/support.rs +++ b/src-tauri/src/commands/providers/tests/support.rs @@ -98,6 +98,7 @@ impl CommandProbe { stdout: String::new(), stderr: String::new(), not_found: false, + scope_error: None, } } @@ -107,6 +108,7 @@ impl CommandProbe { stdout: stdout.to_string(), stderr: String::new(), not_found: false, + scope_error: None, } } @@ -116,6 +118,7 @@ impl CommandProbe { stdout: String::new(), stderr: stderr.to_string(), not_found: false, + scope_error: None, } } } diff --git a/src-tauri/src/jobs.rs b/src-tauri/src/jobs.rs index 7527ed5..9117bc5 100644 --- a/src-tauri/src/jobs.rs +++ b/src-tauri/src/jobs.rs @@ -1,22 +1,27 @@ -use std::{ - collections::HashMap, - process::Child, - sync::{ - Arc, Mutex, - atomic::{AtomicBool, Ordering}, - }, -}; +use std::{collections::HashMap, sync::Mutex}; use serde::Serialize; +mod control; +pub use control::JobControl; + const PARTIAL_TEXT_LIMIT: usize = 32_000; const TERMINAL_JOB_LIMIT: usize = 32; const MAX_ACTIVE_GENERATION_JOBS: usize = 3; #[derive(Clone, Debug, Eq, PartialEq)] pub enum JobStoreError { - Capacity { limit: usize }, - NotFound { job_id: String }, + Capacity { + limit: usize, + }, + NotFound { + job_id: String, + }, + InvalidTransition { + job_id: String, + from: GenerationJobState, + to: GenerationJobState, + }, Internal(String), } @@ -36,6 +41,10 @@ impl std::fmt::Display for JobStoreError { JobStoreError::NotFound { job_id } => { write!(formatter, "Generation job {job_id} was not found.") } + JobStoreError::InvalidTransition { job_id, from, to } => write!( + formatter, + "Generation job {job_id} cannot transition from {from:?} to {to:?}." + ), JobStoreError::Internal(message) => formatter.write_str(message), } } @@ -87,77 +96,6 @@ impl GenerationJobState { } } -#[derive(Clone, Default)] -pub struct JobControl { - cancel_requested: Arc, - child: Arc>>, -} - -impl JobControl { - pub fn is_cancelled(&self) -> bool { - self.cancel_requested.load(Ordering::SeqCst) - } - - pub fn set_child(&self, mut child: Child) -> Result<(), String> { - let mut slot = self - .child - .lock() - .map_err(|_| "Generation process lock was poisoned".to_string())?; - if self.is_cancelled() { - crate::process_io::kill_child_group(&mut child); - } - *slot = Some(child); - Ok(()) - } - - pub fn take_child(&self) -> Result, String> { - self.child - .lock() - .map(|mut child| child.take()) - .map_err(|_| "Generation process lock was poisoned".to_string()) - } - - /// Kill the registered child (and its process group on unix) in place, - /// without removing or reaping it. Used by the cancellation and watchdog - /// paths so the owning worker thread can still `wait()` on it afterwards. - pub fn kill_registered_child(&self) -> Result<(), String> { - if let Some(child) = self - .child - .lock() - .map_err(|_| "Generation process lock was poisoned".to_string())? - .as_mut() - { - crate::process_io::kill_child_group(child); - } - Ok(()) - } - - pub(crate) fn request_cancel(&self) -> Result<(), String> { - self.cancel_requested.store(true, Ordering::SeqCst); - if let Some(child) = self - .child - .lock() - .map_err(|_| "Generation process lock was poisoned".to_string())? - .as_mut() - { - crate::process_io::kill_child_group(child); - } - Ok(()) - } - - /// Kill the registered child (and its process group on unix) without - /// reaping it. Used on app exit to make sure spawned CLIs and their - /// grandchildren do not outlive the app. The owning worker thread is - /// still responsible for `wait()`ing on its own child. - fn kill_child_for_exit(&self) { - if let Ok(mut slot) = self.child.lock() - && let Some(child) = slot.as_mut() - { - crate::process_io::kill_child_group(child); - } - } -} - struct JobRecord { status: GenerationJobStatus, control: JobControl, @@ -230,15 +168,30 @@ impl JobStore { Ok((status, control)) } - /// Kill every live child process (and its process group on unix). - /// Called on app exit so provider CLIs and their grandchildren - /// (node, MCP servers) are not orphaned. + /// Accept cancellation for every active job and kill its live child process + /// (and process group on unix). Called on app exit so provider CLIs and + /// their grandchildren (node, MCP servers) are not orphaned and workers + /// cannot continue into persistence while shutdown is in progress. pub fn kill_all_children(&self) { - let Ok(inner) = self.inner.lock() else { - return; + let jobs = match self.inner.lock() { + Ok(inner) => inner + .jobs + .iter() + .filter(|(_, record)| record.status.state.is_active()) + .map(|(job_id, record)| (job_id.clone(), record.control.clone())) + .collect::>(), + Err(_) => return, }; - for record in inner.jobs.values() { - record.control.kill_child_for_exit(); + + // A finalizer may own one job's serialization gate. Signal and kill + // every process before status reconciliation can wait on that gate. + for (_, control) in &jobs { + let _ = control.request_cancel(); + } + for (job_id, control) in jobs { + if self.cancel(&job_id).is_err() { + control.kill_child_for_exit(); + } } } @@ -278,12 +231,17 @@ impl JobStore { ai_run_id: String, progress_message: &str, ) -> Result { - self.update(job_id, |status| { - status.state = GenerationJobState::Running; - status.ai_run_id = Some(ai_run_id); - status.progress_message = progress_message.to_string(); - status.error_message = None; - }) + self.transition( + job_id, + &[GenerationJobState::Starting], + GenerationJobState::Running, + |status| { + status.state = GenerationJobState::Running; + status.ai_run_id = Some(ai_run_id); + status.progress_message = progress_message.to_string(); + status.error_message = None; + }, + ) } pub fn update_progress( @@ -292,7 +250,10 @@ impl JobStore { progress_message: &str, ) -> Result { self.update(job_id, |status| { - if status.state != GenerationJobState::Cancelling { + if matches!( + status.state, + GenerationJobState::Starting | GenerationJobState::Running + ) { status.progress_message = progress_message.to_string(); } }) @@ -304,16 +265,23 @@ impl JobStore { partial_text: &str, ) -> Result { self.update(job_id, |status| { - status.partial_text = Some(tail(partial_text, PARTIAL_TEXT_LIMIT)); + if status.state == GenerationJobState::Running { + status.partial_text = Some(tail(partial_text, PARTIAL_TEXT_LIMIT)); + } }) } pub fn complete(&self, job_id: &str) -> Result { - self.finish(job_id, |status| { - status.state = GenerationJobState::Completed; - status.progress_message = "Generation completed".to_string(); - status.error_message = None; - }) + self.finish( + job_id, + &[GenerationJobState::Running], + GenerationJobState::Completed, + |status| { + status.state = GenerationJobState::Completed; + status.progress_message = "Generation completed".to_string(); + status.error_message = None; + }, + ) } pub fn fail( @@ -321,49 +289,107 @@ impl JobStore { job_id: &str, error_message: &str, ) -> Result { - self.finish(job_id, |status| { - status.state = GenerationJobState::Failed; - status.progress_message = "Generation failed".to_string(); - status.error_message = Some(error_message.to_string()); - }) + self.finish( + job_id, + &[GenerationJobState::Starting, GenerationJobState::Running], + GenerationJobState::Failed, + |status| { + status.state = GenerationJobState::Failed; + status.progress_message = "Generation failed".to_string(); + status.error_message = Some(error_message.to_string()); + }, + ) } pub fn cancel(&self, job_id: &str) -> Result { - let record_control = { - let mut inner = self - .inner - .lock() - .map_err(|_| JobStoreError::internal("Job store lock was poisoned"))?; - let record = inner - .jobs - .get_mut(job_id) - .ok_or_else(|| JobStoreError::NotFound { - job_id: job_id.to_string(), - })?; - if matches!( - record.status.state, - GenerationJobState::Completed - | GenerationJobState::Failed - | GenerationJobState::Cancelled - ) { - return Ok(record.status.clone()); - } - record.status.state = GenerationJobState::Cancelling; - record.status.progress_message = "Cancelling generation".to_string(); - record.control.clone() - }; - record_control - .request_cancel() - .map_err(JobStoreError::internal)?; - self.status(job_id) + let control = self + .inner + .lock() + .map_err(|_| JobStoreError::internal("Job store lock was poisoned"))? + .jobs + .get(job_id) + .map(|record| record.control.clone()) + .ok_or_else(|| JobStoreError::NotFound { + job_id: job_id.to_string(), + })?; + + control + .run_serialized(|| { + let (status, accepted) = { + let mut inner = self + .inner + .lock() + .map_err(|_| JobStoreError::internal("Job store lock was poisoned"))?; + let record = + inner + .jobs + .get_mut(job_id) + .ok_or_else(|| JobStoreError::NotFound { + job_id: job_id.to_string(), + })?; + if matches!( + record.status.state, + GenerationJobState::Completed + | GenerationJobState::Failed + | GenerationJobState::Cancelled + ) { + return Ok(record.status.clone()); + } + if record.status.state == GenerationJobState::Cancelling { + (record.status.clone(), false) + } else { + record.status.state = GenerationJobState::Cancelling; + record.status.progress_message = "Cancelling generation".to_string(); + (record.status.clone(), true) + } + }; + if accepted { + control.request_cancel().map_err(JobStoreError::internal)?; + } + Ok(status) + }) + .map_err(JobStoreError::internal)? } pub fn mark_cancelled(&self, job_id: &str) -> Result { - self.finish(job_id, |status| { - status.state = GenerationJobState::Cancelled; - status.progress_message = "Generation cancelled".to_string(); - status.error_message = Some("Generation cancelled.".to_string()); - }) + self.finish( + job_id, + &[GenerationJobState::Cancelling], + GenerationJobState::Cancelled, + |status| { + status.state = GenerationJobState::Cancelled; + status.progress_message = "Generation cancelled".to_string(); + status.error_message = Some("Generation cancelled.".to_string()); + }, + ) + } + + fn transition( + &self, + job_id: &str, + allowed_from: &[GenerationJobState], + target: GenerationJobState, + apply: impl FnOnce(&mut GenerationJobStatus), + ) -> Result { + let mut inner = self + .inner + .lock() + .map_err(|_| JobStoreError::internal("Job store lock was poisoned"))?; + let record = inner + .jobs + .get_mut(job_id) + .ok_or_else(|| JobStoreError::NotFound { + job_id: job_id.to_string(), + })?; + if !allowed_from.contains(&record.status.state) { + return Err(JobStoreError::InvalidTransition { + job_id: job_id.to_string(), + from: record.status.state, + to: target, + }); + } + apply(&mut record.status); + Ok(record.status.clone()) } fn update( @@ -388,6 +414,8 @@ impl JobStore { fn finish( &self, job_id: &str, + allowed_from: &[GenerationJobState], + target: GenerationJobState, apply: impl FnOnce(&mut GenerationJobStatus), ) -> Result { let mut inner = self @@ -402,6 +430,13 @@ impl JobStore { .ok_or_else(|| JobStoreError::NotFound { job_id: job_id.to_string(), })?; + if !allowed_from.contains(&record.status.state) { + return Err(JobStoreError::InvalidTransition { + job_id: job_id.to_string(), + from: record.status.state, + to: target, + }); + } apply(&mut record.status); let needs_terminal_sequence = record.status.state.is_terminal() && record.terminal_sequence.is_none(); diff --git a/src-tauri/src/jobs/control.rs b/src-tauri/src/jobs/control.rs new file mode 100644 index 0000000..c6c19ab --- /dev/null +++ b/src-tauri/src/jobs/control.rs @@ -0,0 +1,93 @@ +use std::{ + process::Child, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, +}; + +#[derive(Clone, Default)] +pub struct JobControl { + cancel_requested: Arc, + child: Arc>>, + finalization: Arc>, +} + +impl JobControl { + pub fn is_cancelled(&self) -> bool { + self.cancel_requested.load(Ordering::SeqCst) + } + + pub fn set_child(&self, mut child: Child) -> Result<(), String> { + let mut slot = self + .child + .lock() + .map_err(|_| "Generation process lock was poisoned".to_string())?; + if self.is_cancelled() { + crate::process_io::kill_child_group(&mut child); + } + *slot = Some(child); + Ok(()) + } + + pub fn take_child(&self) -> Result, String> { + self.child + .lock() + .map(|mut child| child.take()) + .map_err(|_| "Generation process lock was poisoned".to_string()) + } + + /// Kill the registered child (and its process group on unix) in place, + /// without removing or reaping it. Used by cancellation and watchdogs so + /// the owning worker can still `wait()` on it afterwards. + pub fn kill_registered_child(&self) -> Result<(), String> { + if let Some(child) = self + .child + .lock() + .map_err(|_| "Generation process lock was poisoned".to_string())? + .as_mut() + { + crate::process_io::kill_child_group(child); + } + Ok(()) + } + + pub(crate) fn request_cancel(&self) -> Result<(), String> { + self.cancel_requested.store(true, Ordering::SeqCst); + self.kill_registered_child() + } + + pub(super) fn run_serialized(&self, operation: impl FnOnce() -> R) -> Result { + let _finalization = self + .finalization + .lock() + .map_err(|_| "Generation finalization lock was poisoned".to_string())?; + Ok(operation()) + } + + /// Run the persistence boundary only if cancellation has not already won. + /// Cancellation and persistence serialize here, so an accepted cancel can + /// never create a generated record afterwards. + pub(crate) fn run_if_not_cancelled( + &self, + operation: impl FnOnce() -> R, + ) -> Result, String> { + self.run_serialized(|| { + if self.is_cancelled() { + None + } else { + Some(operation()) + } + }) + } + + /// Last-resort process cleanup when a normal cancellation transition could + /// not be recorded during app exit. + pub(super) fn kill_child_for_exit(&self) { + if let Ok(mut slot) = self.child.lock() + && let Some(child) = slot.as_mut() + { + crate::process_io::kill_child_group(child); + } + } +} diff --git a/src-tauri/src/jobs/tests.rs b/src-tauri/src/jobs/tests.rs index 0a1fe61..672b273 100644 --- a/src-tauri/src/jobs/tests.rs +++ b/src-tauri/src/jobs/tests.rs @@ -1,5 +1,17 @@ +use std::{ + sync::{Arc, Barrier, mpsc}, + thread, + time::{Duration, Instant}, +}; + use super::{GenerationJobState, JobStore, JobStoreError, MAX_ACTIVE_GENERATION_JOBS}; +fn mark_running(store: &JobStore, job_id: &str) { + store + .mark_running(job_id, format!("run-{job_id}"), "Provider started") + .expect("job starts"); +} + #[test] fn job_store_tracks_active_lifecycle() { let store = JobStore::default(); @@ -47,6 +59,7 @@ fn active_jobs_lists_only_non_terminal_snapshots() { "finding".to_string(), ) .expect("job inserts"); + mark_running(&store, "job-done"); store.complete("job-done").expect("job completes"); let active = store.active_jobs().expect("active jobs list"); @@ -87,6 +100,7 @@ fn terminal_jobs_are_bounded_but_recent_status_is_available() { "summary".to_string(), ) .expect("job inserts"); + mark_running(&store, &job_id); store.complete(&job_id).expect("job completes"); } @@ -120,6 +134,7 @@ fn active_generation_jobs_are_bounded() { assert_eq!(error, JobStoreError::Capacity { limit: 3 }); + mark_running(&store, "job-0"); store.complete("job-0").expect("one active job completes"); store .insert_generation_job( @@ -129,3 +144,268 @@ fn active_generation_jobs_are_bounded() { ) .expect("new job inserts after active slot opens"); } + +#[test] +fn cancelling_job_cannot_return_to_running_or_complete() { + let store = JobStore::default(); + store + .insert_generation_job( + "job-race".to_string(), + "session-1".to_string(), + "summary".to_string(), + ) + .expect("job inserts"); + store.cancel("job-race").expect("cancellation is accepted"); + + for error in [ + store + .mark_running("job-race", "run-race".to_string(), "Provider started") + .unwrap_err(), + store.complete("job-race").unwrap_err(), + store.fail("job-race", "late failure").unwrap_err(), + ] { + assert!(matches!( + error, + JobStoreError::InvalidTransition { + from: GenerationJobState::Cancelling, + .. + } + )); + } + assert_eq!( + store.status("job-race").unwrap().state, + GenerationJobState::Cancelling + ); + store + .update_progress("job-race", "late progress") + .expect("late progress is ignored"); + store + .update_partial("job-race", "late partial") + .expect("late partial is ignored"); + let cancelling = store.status("job-race").unwrap(); + assert_eq!(cancelling.progress_message, "Cancelling generation"); + assert_eq!(cancelling.partial_text, None); +} + +#[test] +fn accepted_cancellation_prevents_final_persistence_operation() { + let store = JobStore::default(); + let (_, control) = store + .insert_generation_job( + "job-before-persist".to_string(), + "session-1".to_string(), + "summary".to_string(), + ) + .expect("job inserts"); + mark_running(&store, "job-before-persist"); + store + .cancel("job-before-persist") + .expect("cancellation is accepted"); + let mut persisted = false; + + let result = control + .run_if_not_cancelled(|| persisted = true) + .expect("finalization gate is available"); + + assert_eq!(result, None); + assert!(!persisted, "accepted cancellation must skip persistence"); +} + +#[test] +fn cancellation_waiting_behind_persistence_is_not_accepted() { + let store = Arc::new(JobStore::default()); + let (_, control) = store + .insert_generation_job( + "job-finalizing".to_string(), + "session-1".to_string(), + "summary".to_string(), + ) + .expect("job inserts"); + mark_running(&store, "job-finalizing"); + let entered = Arc::new(Barrier::new(2)); + let release = Arc::new(Barrier::new(2)); + let worker_store = Arc::clone(&store); + let worker_entered = Arc::clone(&entered); + let worker_release = Arc::clone(&release); + let worker = thread::spawn(move || { + control + .run_if_not_cancelled(|| { + worker_entered.wait(); + worker_release.wait(); + worker_store + .complete("job-finalizing") + .expect("finalizer completes job") + }) + .expect("finalization lock is available") + .expect("cancellation did not precede finalization") + }); + entered.wait(); + let cancel_store = Arc::clone(&store); + let (cancel_tx, cancel_rx) = mpsc::channel(); + let canceller = thread::spawn(move || { + let _ = cancel_tx.send(cancel_store.cancel("job-finalizing")); + }); + assert!( + cancel_rx.recv_timeout(Duration::from_millis(50)).is_err(), + "cancellation must wait while persistence owns the decision gate" + ); + release.wait(); + + assert_eq!(worker.join().unwrap().state, GenerationJobState::Completed); + assert_eq!( + cancel_rx + .recv_timeout(Duration::from_secs(1)) + .expect("cancellation returns after finalization") + .expect("terminal status is returned") + .state, + GenerationJobState::Completed + ); + canceller.join().unwrap(); +} + +#[test] +fn shutdown_accepts_cancellation_for_starting_and_running_jobs() { + let store = JobStore::default(); + let (_, starting_control) = store + .insert_generation_job( + "job-starting".to_string(), + "session-1".to_string(), + "summary".to_string(), + ) + .expect("starting job inserts"); + let (_, running_control) = store + .insert_generation_job( + "job-running".to_string(), + "session-1".to_string(), + "summary".to_string(), + ) + .expect("running job inserts"); + mark_running(&store, "job-running"); + + store.kill_all_children(); + + for (job_id, control) in [ + ("job-starting", starting_control), + ("job-running", running_control), + ] { + assert!(control.is_cancelled()); + assert_eq!( + store.status(job_id).unwrap().state, + GenerationJobState::Cancelling + ); + } +} + +#[cfg(unix)] +#[test] +fn shutdown_broadcasts_cancellation_and_kills_children_before_finalization_waits() { + let store = Arc::new(JobStore::default()); + let mut controls = Vec::new(); + for job_id in ["job-a", "job-b", "job-c"] { + let (_, control) = store + .insert_generation_job( + job_id.to_string(), + "session-1".to_string(), + "summary".to_string(), + ) + .expect("job inserts"); + mark_running(&store, job_id); + controls.push(control); + } + + let mut command = std::process::Command::new("sh"); + command.args(["-c", "sleep 30"]); + crate::process_io::configure_process_group(&mut command); + controls[2] + .set_child(command.spawn().expect("test child starts")) + .expect("test child registers"); + + let release = Arc::new(Barrier::new(controls.len() + 1)); + let (entered_tx, entered_rx) = mpsc::channel(); + let holders = controls + .iter() + .cloned() + .map(|control| { + let release = Arc::clone(&release); + let entered_tx = entered_tx.clone(); + thread::spawn(move || { + control + .run_if_not_cancelled(|| { + entered_tx.send(()).expect("holder reports entry"); + release.wait(); + }) + .expect("finalization gate remains available") + }) + }) + .collect::>(); + drop(entered_tx); + for _ in &controls { + entered_rx + .recv_timeout(Duration::from_secs(1)) + .expect("every finalization gate is held"); + } + + let shutdown_store = Arc::clone(&store); + let (shutdown_tx, shutdown_rx) = mpsc::channel(); + let shutdown = thread::spawn(move || { + shutdown_store.kill_all_children(); + let _ = shutdown_tx.send(()); + }); + + let deadline = Instant::now() + Duration::from_secs(1); + while !controls.iter().all(|control| control.is_cancelled()) && Instant::now() < deadline { + thread::sleep(Duration::from_millis(5)); + } + let all_cancelled = controls.iter().all(|control| control.is_cancelled()); + let shutdown_waited = shutdown_rx.recv_timeout(Duration::from_millis(50)).is_err(); + + let mut child = controls[2] + .take_child() + .expect("child lock remains available") + .expect("test child remains registered"); + let deadline = Instant::now() + Duration::from_secs(1); + let child_killed = loop { + if child + .try_wait() + .expect("child status is readable") + .is_some() + { + break true; + } + if Instant::now() >= deadline { + break false; + } + thread::sleep(Duration::from_millis(5)); + }; + if !child_killed { + crate::process_io::kill_child_group(&mut child); + let _ = child.wait(); + } + + release.wait(); + for holder in holders { + holder.join().expect("finalization holder exits"); + } + if shutdown_waited { + shutdown_rx + .recv_timeout(Duration::from_secs(1)) + .expect("shutdown finishes after finalization gates release"); + } + shutdown.join().expect("shutdown exits"); + + assert!(all_cancelled, "shutdown must broadcast before waiting"); + assert!( + child_killed, + "shutdown must kill registered provider children" + ); + assert!( + shutdown_waited, + "status reconciliation should still serialize" + ); + for job_id in ["job-a", "job-b", "job-c"] { + assert_eq!( + store.status(job_id).unwrap().state, + GenerationJobState::Cancelling + ); + } +} diff --git a/src-tauri/src/provider_command.rs b/src-tauri/src/provider_command.rs index 4b571ba..f614a2a 100644 --- a/src-tauri/src/provider_command.rs +++ b/src-tauri/src/provider_command.rs @@ -45,26 +45,27 @@ pub enum ProviderPathMode { /// working directory, so both paths must use the same neutral-scope policy. pub struct NeutralProviderCwd { path: PathBuf, - owned: bool, } impl NeutralProviderCwd { - pub fn new() -> Self { - let unique = env::temp_dir().join(format!( + pub fn new() -> std::io::Result { + Self::new_in(&env::temp_dir()) + } + + pub(crate) fn new_in(parent: &Path) -> std::io::Result { + let unique = parent.join(format!( "qa-scribe-provider-cwd-{}", uuid::Uuid::new_v4().simple() )); - if create_private_provider_directory(&unique).is_ok() { - Self { - path: unique, - owned: true, - } - } else { - Self { - path: env::temp_dir(), - owned: false, - } - } + create_private_provider_directory(&unique).map_err(|error| { + std::io::Error::new( + error.kind(), + format!( + "Could not create a private working directory for the provider. The provider was not started: {error}" + ), + ) + })?; + Ok(Self { path: unique }) } pub fn path(&self) -> &Path { @@ -82,17 +83,9 @@ fn create_private_provider_directory(path: &Path) -> std::io::Result<()> { builder.create(path) } -impl Default for NeutralProviderCwd { - fn default() -> Self { - Self::new() - } -} - impl Drop for NeutralProviderCwd { fn drop(&mut self) { - if self.owned { - let _ = fs::remove_dir_all(&self.path); - } + let _ = fs::remove_dir_all(&self.path); } } diff --git a/src-tauri/src/provider_command/tests.rs b/src-tauri/src/provider_command/tests.rs index a8ff0e9..1fa9ee9 100644 --- a/src-tauri/src/provider_command/tests.rs +++ b/src-tauri/src/provider_command/tests.rs @@ -89,7 +89,7 @@ fn nvm_fallback_prefers_numeric_node_versions_over_lexical_order() { #[cfg(unix)] #[test] fn neutral_provider_directory_is_private() { - let directory = NeutralProviderCwd::new(); + let directory = NeutralProviderCwd::new().expect("private provider directory should create"); assert_eq!( fs::metadata(directory.path()) @@ -101,6 +101,27 @@ fn neutral_provider_directory_is_private() { ); } +#[test] +fn neutral_provider_directory_creation_fails_closed() { + let blocked_parent = std::env::temp_dir().join(format!( + "qa-scribe-provider-cwd-blocked-{}", + uuid::Uuid::new_v4().simple() + )); + fs::write(&blocked_parent, b"not a directory").expect("blocking file should create"); + + let error = NeutralProviderCwd::new_in(&blocked_parent) + .err() + .expect("a file cannot own a child provider directory"); + + assert!( + error + .to_string() + .contains("Could not create a private working directory for the provider") + ); + assert!(error.to_string().contains("provider was not started")); + let _ = fs::remove_file(blocked_parent); +} + #[test] fn executable_resolution_uses_supplied_provider_path() { let test_dir = std::env::temp_dir().join(format!( diff --git a/src-tauri/src/specta_bindings.rs b/src-tauri/src/specta_bindings.rs index 672f52a..c1c9aa8 100644 --- a/src-tauri/src/specta_bindings.rs +++ b/src-tauri/src/specta_bindings.rs @@ -76,6 +76,7 @@ pub fn builder() -> Builder { commands::ai::job_runner::list_active_ai_action_jobs, commands::ai::job_runner::cancel_ai_action_job, commands::files::import_clipboard_screenshot, + commands::files::delete_attachment, commands::providers::get_provider_status, commands::providers::refresh_provider_status, commands::files::get_attachment_preview_data_url, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 110dd0b..2512c5e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "QA Scribe", - "version": "0.7.20", + "version": "0.7.21", "identifier": "io.github.ddv1982.qa-scribe", "build": { "beforeDevCommand": "cd frontend && bun run dev", From d85fbaa67ccec965eb72e3069a1ec0c965a6eda1 Mon Sep 17 00:00:00 2001 From: Douwe de Vries Date: Tue, 21 Jul 2026 22:21:46 +0200 Subject: [PATCH 2/3] fix(ci): refresh Rust toolchain action pin --- .github/actions/setup-project/action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/setup-project/action.yml b/.github/actions/setup-project/action.yml index 7d18925..76f7455 100644 --- a/.github/actions/setup-project/action.yml +++ b/.github/actions/setup-project/action.yml @@ -44,14 +44,14 @@ runs: - name: Install Rust if: inputs.rust-targets == '' - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable branch (2026-06-30) + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable branch (2026-07-16) with: toolchain: ${{ steps.rust-toolchain.outputs.channel }} components: clippy,rustfmt - name: Install Rust with additional targets if: inputs.rust-targets != '' - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable branch (2026-06-30) + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable branch (2026-07-16) with: toolchain: ${{ steps.rust-toolchain.outputs.channel }} targets: ${{ inputs.rust-targets }} From 02822da690ea052c72ab60f723f8f5efd36988ec Mon Sep 17 00:00:00 2001 From: Douwe de Vries Date: Tue, 21 Jul 2026 22:47:37 +0200 Subject: [PATCH 3/3] fix(e2e): allow noisy WDIO logs --- scripts/run-e2e.mjs | 2 ++ scripts/run-e2e.test.mjs | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/run-e2e.mjs b/scripts/run-e2e.mjs index 735f33c..c4ec02e 100755 --- a/scripts/run-e2e.mjs +++ b/scripts/run-e2e.mjs @@ -9,6 +9,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') const fixtureBin = join(root, 'e2e', 'fixtures', 'bin') const criticalSpec = 'critical-workflows.e2e.mjs' +const wdioMaxBuffer = 64 * 1024 * 1024 export const criticalScenarios = Object.freeze([ 'session-lifecycle', @@ -161,6 +162,7 @@ export function runE2e(environment = process.env, { spawnSyncImpl = spawnSync } cwd: root, env: invocationEnvironment, encoding: 'utf8', + maxBuffer: wdioMaxBuffer, shell: process.platform === 'win32', }) const stdout = completed.stdout ?? '' diff --git a/scripts/run-e2e.test.mjs b/scripts/run-e2e.test.mjs index 89bcbfd..50a9e99 100644 --- a/scripts/run-e2e.test.mjs +++ b/scripts/run-e2e.test.mjs @@ -92,7 +92,7 @@ for (const [label, wdioStatus] of [['successful', 0], ['failed', 4]]) { }, { spawnSyncImpl(command, args, options) { - calls.push({ command, args, environment: options.env }) + calls.push({ command, args, environment: options.env, maxBuffer: options.maxBuffer }) return { status: command.includes('wdio') ? wdioStatus : 0, signal: null, stdout: '', stderr: '' } }, }, @@ -102,8 +102,10 @@ for (const [label, wdioStatus] of [['successful', 0], ['failed', 4]]) { assert.equal(calls.filter(({ command }) => command === 'bun').length, 1) assert.equal(calls.filter(({ command }) => command === 'cargo').length, 1) const frontendBuild = calls.find(({ command }) => command === 'bun') + const wdioRun = calls.find(({ command }) => command.includes('wdio')) const outputIndex = frontendBuild.args.indexOf('--outDir') assert.equal(frontendBuild.args[outputIndex + 1], join(temporaryRoot, 'isolated', 'frontend-dist')) + assert.equal(wdioRun.maxBuffer, 64 * 1024 * 1024) assert.ok(calls.every(({ args }) => !args.includes(resolve('frontend/dist')))) } finally { rmSync(temporaryRoot, { recursive: true, force: true })