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#"
tail"#;
+ 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#"
B",
+ "café > naïve",
+ "日本語 > ✅",
+ "nested-looking value > threshold",
+ "entity > plus literal > delimiter",
+ ];
+
+ for quote in ['"', '\''] {
+ for payload in payloads {
+ let value = format!(
+ "
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#"
"#;
+ let response = r#"
"#;
+
+ 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#"
"#;
+
+ 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#"
"#;
+
+ let preserved = preserve_managed_attachment_images(
+ r#"
"#,
+ 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.
"#;
+
+ 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.
"#;
+
+ 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#"
"#,
+ 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.
"#,
+ original,
+ &[attachment],
+ );
+ assert_eq!(
+ exact
+ .match_indices("data-attachment-id=\"attachment-1\"")
+ .count(),
+ 1
+ );
+ assert_eq!(exact.matches("
Evidence.
"#);
+
+ for response in [
+ format!("Mentions {source} in text.
"),
+ format!(r#"Evidence link"#),
+ format!(r#"Removed attribute
"#),
+ format!(r#"
"#),
+ ] {
+ 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.
"#),
+ &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!("
")));
+ assert_eq!(preserved.matches("
"#;
+ 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("![]()
\
+
"
+ );
+
+ 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é.
+
+ 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#"
After
"#,
+ );
+ assert!(nested.contains("[Image: Nested value > threshold]"));
+ assert!(nested.contains("After"));
+
+ let malformed = project_html_to_prompt_text(
+ r#"Before
"));
+ 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!(
+ "
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
+
"#,
+ );
+
+ assert!(sanitized.contains("café 比較
"));
+ assert!(sanitized.contains(
+ "Comparison"
+ ));
+ assert!(sanitized.contains(
+ "
"
+ ));
+ 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#"
+
+
+
+
"#,
+ );
+ 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
Before<img"));
+ assert!(sanitized.contains("alt=\"unclosed > "));
+ assert!(sanitized.contains("After
"));
+ assert!(!sanitized.contains("']
@@ -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}
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