test(desktop): cover bridge, backup and package contracts
This commit is contained in:
1 parent
6cc27a78c8
commit
f526f4ad5b
25 files changed
+7665
No files matched your search
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 --artifact <portable.tar.gz> --expected-commit <sha> [--work-dir <dir>]" >&2
|
||||
}
|
||||
|
||||
artifact=""
|
||||
expected_commit=""
|
||||
work_dir=""
|
||||
keep_work_dir=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--artifact) artifact="$2"; shift 2 ;;
|
||||
--expected-commit) expected_commit="$2"; shift 2 ;;
|
||||
--work-dir) work_dir="$2"; shift 2 ;;
|
||||
--keep-work-dir) keep_work_dir=1; shift ;;
|
||||
*) usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$artifact" && -n "$expected_commit" ]] || { usage; exit 2; }
|
||||
artifact="$(realpath "$artifact")"
|
||||
[[ -f "$artifact" ]] || { echo "Artifact not found: $artifact" >&2; exit 1; }
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
driver="$script_dir/../package/real_package_journey.py"
|
||||
if [[ -z "$work_dir" ]]; then
|
||||
work_dir="$(mktemp -d -t ga-linux-package-e2e.XXXXXX)"
|
||||
else
|
||||
mkdir -p "$work_dir"
|
||||
work_dir="$(realpath "$work_dir")"
|
||||
fi
|
||||
extract_dir="$work_dir/extracted"
|
||||
report_dir="$work_dir/report"
|
||||
driver_work="$work_dir/driver-work"
|
||||
mkdir -p "$extract_dir" "$report_dir" "$driver_work"
|
||||
|
||||
cleanup() {
|
||||
if [[ $keep_work_dir -eq 0 ]]; then
|
||||
echo "Evidence kept at $report_dir; extracted package kept until the report is collected."
|
||||
else
|
||||
echo "Work directory kept at $work_dir"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
tar -xzf "$artifact" -C "$extract_dir"
|
||||
package_root="$extract_dir/GenericAgent-Desktop-Linux-Portable"
|
||||
if [[ ! -d "$package_root" ]]; then
|
||||
mapfile -t roots < <(find "$extract_dir" -mindepth 1 -maxdepth 1 -type d)
|
||||
[[ ${#roots[@]} -eq 1 ]] || { echo "Could not identify the portable package root" >&2; exit 1; }
|
||||
package_root="${roots[0]}"
|
||||
fi
|
||||
chmod +x "$package_root/GenericAgent.AppImage"
|
||||
|
||||
sidecar="$(dirname "$artifact")/SHA256SUMS-linux.txt"
|
||||
[[ -f "$sidecar" ]] || { echo "Linux SHA-256 sidecar not found: $sidecar" >&2; exit 1; }
|
||||
expected_hash="$(grep -Eo '[0-9a-fA-F]{64}' "$sidecar" | head -n 1 | tr '[:upper:]' '[:lower:]')"
|
||||
actual_hash="$(sha256sum "$artifact" | awk '{print $1}')"
|
||||
[[ "$actual_hash" == "$expected_hash" ]] || { echo "Artifact SHA-256 mismatch" >&2; exit 1; }
|
||||
|
||||
python3 "$driver" \
|
||||
--platform linux \
|
||||
--artifact "$artifact" \
|
||||
--expected-commit "$expected_commit" \
|
||||
--package-root "$package_root" \
|
||||
--application-relative "GenericAgent.AppImage" \
|
||||
--runtime-relative "runtime" \
|
||||
--relocated-root "$work_dir/relocated/含 空格/GenericAgent 包" \
|
||||
--report-dir "$report_dir" \
|
||||
--work-dir "$driver_work" \
|
||||
--allow-user-settings-mutation
|
||||
@@ -0,0 +1,22 @@
|
||||
# Ubuntu 24.04 release-package journey
|
||||
|
||||
Run this on a real Ubuntu 24.04 x64 desktop in a dedicated test account. FUSE and a screenshot
|
||||
tool (`gnome-screenshot`, `scrot`, or ImageMagick `import`) must be available.
|
||||
|
||||
```bash
|
||||
frontends/desktop/e2e/linux/Invoke-LinuxUserJourney.sh \
|
||||
--artifact /path/GenericAgent-Desktop-Linux-Portable.tar.gz \
|
||||
--expected-commit <candidate-sha> \
|
||||
--keep-work-dir
|
||||
```
|
||||
|
||||
The wrapper verifies/extracts the tar, preserves the AppImage launch path, and runs the shared
|
||||
production journey. The journey covers package shape, embedded Python, first launch, warm
|
||||
restart, package-owned bridge plus external `GA_ROOT`, deterministic chat, upload, memory import,
|
||||
foreign-port protection, recovery after release, relocation into a path containing spaces and
|
||||
Chinese characters, stale-override fallback, optional P2P degradation, screenshots, and cleanup.
|
||||
|
||||
After automation passes, edit `report/real-package-report.json` and mark the Linux manual items
|
||||
`pass`: executable bit and desktop launcher, window dragging/close behavior, retry button after
|
||||
port release, native directory picker, and visual/loading checks. P2 evidence is incomplete while
|
||||
any manual item remains `pending`.
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 --artifact <desktop.dmg> --expected-commit <sha> [--work-dir <dir>]" >&2
|
||||
}
|
||||
|
||||
artifact=""
|
||||
expected_commit=""
|
||||
work_dir=""
|
||||
keep_work_dir=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--artifact) artifact="$2"; shift 2 ;;
|
||||
--expected-commit) expected_commit="$2"; shift 2 ;;
|
||||
--work-dir) work_dir="$2"; shift 2 ;;
|
||||
--keep-work-dir) keep_work_dir=1; shift ;;
|
||||
*) usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$artifact" && -n "$expected_commit" ]] || { usage; exit 2; }
|
||||
artifact="$(cd "$(dirname "$artifact")" && pwd)/$(basename "$artifact")"
|
||||
[[ -f "$artifact" ]] || { echo "Artifact not found: $artifact" >&2; exit 1; }
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
driver="$script_dir/../package/real_package_journey.py"
|
||||
if [[ -z "$work_dir" ]]; then
|
||||
work_dir="$(mktemp -d -t ga-macos-package-e2e.XXXXXX)"
|
||||
else
|
||||
mkdir -p "$work_dir"
|
||||
work_dir="$(cd "$work_dir" && pwd)"
|
||||
fi
|
||||
report_dir="$work_dir/report"
|
||||
driver_work="$work_dir/driver-work"
|
||||
mount_point="$work_dir/dmg"
|
||||
install_app="/Applications/GenericAgent P2 E2E $$.app"
|
||||
mounted=0
|
||||
mkdir -p "$report_dir" "$driver_work" "$mount_point"
|
||||
|
||||
cleanup() {
|
||||
if [[ $mounted -eq 1 ]]; then
|
||||
hdiutil detach "$mount_point" -quiet || true
|
||||
fi
|
||||
if [[ -d "$install_app" ]]; then
|
||||
rm -rf -- "$install_app"
|
||||
fi
|
||||
if [[ $keep_work_dir -eq 1 ]]; then
|
||||
echo "Work directory kept at $work_dir"
|
||||
else
|
||||
echo "Evidence kept at $report_dir"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
[[ ! -e "$install_app" ]] || { echo "Refusing to replace existing app: $install_app" >&2; exit 1; }
|
||||
sidecar="$artifact.sha256"
|
||||
[[ -f "$sidecar" ]] || { echo "macOS SHA-256 sidecar not found: $sidecar" >&2; exit 1; }
|
||||
expected_hash="$(grep -Eo '[0-9a-fA-F]{64}' "$sidecar" | head -n 1 | tr '[:upper:]' '[:lower:]')"
|
||||
actual_hash="$(shasum -a 256 "$artifact" | awk '{print $1}')"
|
||||
[[ "$actual_hash" == "$expected_hash" ]] || { echo "Artifact SHA-256 mismatch" >&2; exit 1; }
|
||||
|
||||
hdiutil attach "$artifact" -mountpoint "$mount_point" -nobrowse -readonly -quiet
|
||||
mounted=1
|
||||
source_app="$(find "$mount_point" -mindepth 1 -maxdepth 1 -type d -name '*.app' | head -n 1)"
|
||||
[[ -n "$source_app" ]] || { echo "DMG does not contain an application" >&2; exit 1; }
|
||||
ditto "$source_app" "$install_app"
|
||||
hdiutil detach "$mount_point" -quiet
|
||||
mounted=0
|
||||
|
||||
binary="$(find "$install_app/Contents/MacOS" -mindepth 1 -maxdepth 1 -type f -perm -111 | head -n 1)"
|
||||
[[ -n "$binary" ]] || { echo "Installed application has no executable" >&2; exit 1; }
|
||||
binary_relative="${binary#"$install_app"/}"
|
||||
|
||||
python3 "$driver" \
|
||||
--platform macos \
|
||||
--artifact "$artifact" \
|
||||
--expected-commit "$expected_commit" \
|
||||
--package-root "$install_app" \
|
||||
--application-relative "$binary_relative" \
|
||||
--runtime-relative "Contents/Resources/runtime" \
|
||||
--relocated-root "$work_dir/relocated/含 空格/GenericAgent 已移动.app" \
|
||||
--report-dir "$report_dir" \
|
||||
--work-dir "$driver_work" \
|
||||
--allow-user-settings-mutation \
|
||||
--allow-external-package
|
||||
@@ -0,0 +1,27 @@
|
||||
# macOS 15 DMG release-package journey
|
||||
|
||||
Run this on a real macOS 15 machine in a dedicated test account. The wrapper mounts the DMG,
|
||||
copies its app into `/Applications` under a collision-safe E2E name, launches the production
|
||||
binary, and later moves that exact app to a path containing spaces and Chinese characters.
|
||||
|
||||
```bash
|
||||
frontends/desktop/e2e/macos/Invoke-macOSUserJourney.sh \
|
||||
--artifact /path/GenericAgent-Desktop-macOS-aarch64.dmg \
|
||||
--expected-commit <candidate-sha> \
|
||||
--keep-work-dir
|
||||
```
|
||||
|
||||
The release workflow builds this artifact on the explicit macOS 15 arm64 runner and names it
|
||||
`aarch64`; it is not an Intel/universal binary. The app is ad-hoc signed only, not Developer ID
|
||||
signed or notarized.
|
||||
|
||||
In addition to the shared chat/data/port/relocation checks, this journey hard-fails if the DMG
|
||||
does not contain the build-time `.prepared` marker or if any file inside the `.app` changes from
|
||||
first launch through restart and relocation. It verifies the package bridge remains inside the
|
||||
app while `GA_ROOT` points at an external core, then verifies a deleted override falls back to the
|
||||
stable writable runtime at `~/Library/Application Support/GenericAgent/runtime/app`.
|
||||
|
||||
After automation, mark the manual report items `pass`: Gatekeeper/open-anyway, traffic lights,
|
||||
focus, retry button after port release, native directory picker, and visual/loading checks. The
|
||||
wrapper restores the settings file exactly and removes its temporary `/Applications` copy; run it
|
||||
only in a dedicated account because the normal Application Support runtime may be created.
|
||||
@@ -0,0 +1,28 @@
|
||||
# P2 release-package evidence gate
|
||||
|
||||
`real_package_journey.py` is the common L5 runner. Platform wrappers install or extract one
|
||||
artifact produced from a candidate commit and invoke this runner against the production binary.
|
||||
It temporarily uses the real per-user settings path, restores it byte-for-byte, and therefore
|
||||
must run in a dedicated Windows/Linux/macOS test account.
|
||||
|
||||
Every platform report records the candidate commit, artifact SHA-256, OS/architecture, bootstrap
|
||||
snapshots, bridge identity/PIDs, package paths before and after relocation, screenshots, redacted
|
||||
fake-model transcript, data results, cleanup status, and the short manual checklist. Automated
|
||||
success does not imply P2 completion: testers must change every manual checklist value to `pass`.
|
||||
|
||||
After all three reports and the Windows native retry report are complete, combine them:
|
||||
|
||||
```bash
|
||||
python3 frontends/desktop/e2e/package/verify_candidate_evidence.py \
|
||||
--expected-commit <candidate-sha> \
|
||||
--windows <windows-production-contract-report.json> \
|
||||
--linux <linux-real-package-report.json> \
|
||||
--macos <macos-real-package-report.json> \
|
||||
--windows-native-report <windows-e2e-report.json> \
|
||||
--output <candidate-evidence-manifest.json>
|
||||
```
|
||||
|
||||
The verifier fails on a commit mismatch, missing artifact digest, failed automated scenario,
|
||||
incomplete bootstrap evidence, missing macOS app immutability proof, unfinished manual checklist,
|
||||
or unclean final process/port state. The manifest is evidence for the candidate SHA; it is not
|
||||
intended to be committed to the repository.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Combine three real-package reports and enforce the P2 candidate evidence gate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
COMMON_CHECKS = (
|
||||
"packageShape",
|
||||
"deterministicChat",
|
||||
"uploadUnderExternalRoot",
|
||||
"isolatedConductorOwnership",
|
||||
"memoryImport",
|
||||
"warmRestart",
|
||||
"foreignListenerSurvived",
|
||||
"portRecovery",
|
||||
"relocation",
|
||||
"staleOverrideFallback",
|
||||
"optionalP2PDoesNotBlockReady",
|
||||
"settingsRestored",
|
||||
"finalPortFree",
|
||||
"finalConductorPortFree",
|
||||
"defaultConductorPortPreserved",
|
||||
"ownedProcessStops",
|
||||
"finalOwnedProcessesExited",
|
||||
)
|
||||
|
||||
SUCCESSFUL_APPLICATION_SCENARIOS = {
|
||||
"first-launch",
|
||||
"warm-restart",
|
||||
"after-port-release",
|
||||
"relocated",
|
||||
"stale-override",
|
||||
}
|
||||
|
||||
|
||||
def load(path: str) -> dict[str, Any]:
|
||||
target = Path(path).expanduser().resolve()
|
||||
try:
|
||||
value = json.loads(target.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise ValueError(f"cannot read {target}: {error}") from error
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"report is not an object: {target}")
|
||||
value["_path"] = str(target)
|
||||
return value
|
||||
|
||||
|
||||
def assert_report(name: str, report: dict[str, Any], expected_commit: str) -> list[str]:
|
||||
failures: list[str] = []
|
||||
actual_commit = str(report.get("expectedCommit", "")).lower()
|
||||
if actual_commit != expected_commit.lower():
|
||||
failures.append(f"{name}: commit {actual_commit!r} != {expected_commit!r}")
|
||||
if report.get("releaseVersion") != "0.2.0":
|
||||
failures.append(f"{name}: release version is not 0.2.0")
|
||||
if report.get("success") is not True:
|
||||
failures.append(f"{name}: automated journey did not pass")
|
||||
if not str(report.get("artifact", {}).get("sha256", "")):
|
||||
failures.append(f"{name}: artifact SHA-256 is missing")
|
||||
checks = report.get("checks", {})
|
||||
for check in COMMON_CHECKS:
|
||||
if not checks.get(check):
|
||||
failures.append(f"{name}: required check {check} did not pass")
|
||||
environment = report.get("environment")
|
||||
isolated_port = (
|
||||
environment.get("isolatedConductorPort") if isinstance(environment, dict) else None
|
||||
)
|
||||
if (
|
||||
not isinstance(isolated_port, int)
|
||||
or isinstance(isolated_port, bool)
|
||||
or not 1 <= isolated_port <= 65535
|
||||
or isolated_port in {8900, 14168}
|
||||
):
|
||||
failures.append(f"{name}: isolated conductor port is missing or invalid")
|
||||
ownership = checks.get("isolatedConductorOwnership")
|
||||
if not isinstance(ownership, dict) or set(ownership) != SUCCESSFUL_APPLICATION_SCENARIOS:
|
||||
failures.append(f"{name}: conductor ownership does not cover every successful launch")
|
||||
elif isinstance(isolated_port, int):
|
||||
for scenario, state in ownership.items():
|
||||
pid = state.get("pid") if isinstance(state, dict) else None
|
||||
if (
|
||||
not isinstance(state, dict)
|
||||
or state.get("status") != "running"
|
||||
or state.get("running") is not True
|
||||
or state.get("owned") is not True
|
||||
or state.get("external") is not False
|
||||
or state.get("port") != isolated_port
|
||||
or not isinstance(pid, int)
|
||||
or isinstance(pid, bool)
|
||||
or pid <= 0
|
||||
):
|
||||
failures.append(f"{name}: invalid conductor ownership evidence for {scenario}")
|
||||
stops = checks.get("ownedProcessStops")
|
||||
if not isinstance(stops, dict) or set(stops) != SUCCESSFUL_APPLICATION_SCENARIOS:
|
||||
failures.append(f"{name}: owned process stops do not cover every successful launch")
|
||||
else:
|
||||
for scenario, pids in stops.items():
|
||||
expected_conductor_pid = (
|
||||
ownership.get(scenario, {}).get("pid") if isinstance(ownership, dict) else None
|
||||
)
|
||||
if (
|
||||
not isinstance(pids, dict)
|
||||
or set(pids) != {"app", "bridge", "conductor"}
|
||||
or any(
|
||||
not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0
|
||||
for pid in pids.values()
|
||||
)
|
||||
or len(set(pids.values())) != 3
|
||||
or pids.get("conductor") != expected_conductor_pid
|
||||
):
|
||||
failures.append(f"{name}: invalid owned process stop evidence for {scenario}")
|
||||
if name == "macos" and checks.get("macAppImmutable") is not True:
|
||||
failures.append("macos: signed .app immutability did not pass")
|
||||
required_bootstrap = SUCCESSFUL_APPLICATION_SCENARIOS | {"foreign-port"}
|
||||
missing_bootstrap = sorted(required_bootstrap - set(report.get("bootstrap", {})))
|
||||
if missing_bootstrap:
|
||||
failures.append(f"{name}: missing bootstrap evidence {missing_bootstrap}")
|
||||
pending = [key for key, value in report.get("manualChecklist", {}).items() if value != "pass"]
|
||||
if pending:
|
||||
failures.append(f"{name}: manual checklist is incomplete: {pending}")
|
||||
if len(report.get("screenshots", [])) < 2 and name != "windows":
|
||||
failures.append(f"{name}: fewer than two screenshots were recorded")
|
||||
return failures
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--expected-commit", required=True)
|
||||
parser.add_argument("--windows", required=True)
|
||||
parser.add_argument("--linux", required=True)
|
||||
parser.add_argument("--macos", required=True)
|
||||
parser.add_argument("--windows-native-report", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
reports = {
|
||||
"windows": load(args.windows),
|
||||
"linux": load(args.linux),
|
||||
"macos": load(args.macos),
|
||||
}
|
||||
failures: list[str] = []
|
||||
for name, report in reports.items():
|
||||
failures.extend(assert_report(name, report, args.expected_commit))
|
||||
|
||||
windows_native = load(args.windows_native_report)
|
||||
if windows_native.get("success") is not True:
|
||||
failures.append("windows native wrapper did not pass")
|
||||
if windows_native.get("checks", {}).get("portConflictRecovery") is not True:
|
||||
failures.append("windows native retry path did not pass")
|
||||
if windows_native.get("checks", {}).get("settingsRestored") is not True:
|
||||
failures.append("windows native wrapper did not restore the original settings file")
|
||||
native_pending = [
|
||||
key for key, value in windows_native.get("manualChecklist", {}).items() if value != "pass"
|
||||
]
|
||||
if native_pending:
|
||||
failures.append(f"windows native manual checklist is incomplete: {native_pending}")
|
||||
|
||||
manifest = {
|
||||
"schemaVersion": 1,
|
||||
"candidateCommit": args.expected_commit,
|
||||
"releaseVersion": "0.2.0",
|
||||
"platforms": {
|
||||
name: {
|
||||
"report": report["_path"],
|
||||
"artifactSha256": report.get("artifact", {}).get("sha256"),
|
||||
"environment": report.get("environment"),
|
||||
"success": report.get("success"),
|
||||
}
|
||||
for name, report in reports.items()
|
||||
},
|
||||
"windowsNativeReport": windows_native["_path"],
|
||||
"gate": "pass" if not failures else "fail",
|
||||
"failures": failures,
|
||||
}
|
||||
output = Path(args.output).expanduser().resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
if failures:
|
||||
for failure in failures:
|
||||
print(f"FAIL: {failure}", file=sys.stderr)
|
||||
return 1
|
||||
print(output)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,8 @@
|
||||
pytest
|
||||
aiohttp>=3.9
|
||||
requests>=2.28
|
||||
psutil
|
||||
fastapi
|
||||
uvicorn
|
||||
websockets
|
||||
pydantic
|
||||
@@ -0,0 +1,662 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Repo = "abraxas914/GenericAgent",
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RunId,
|
||||
[string]$ExpectedCommit = "",
|
||||
[string]$PackageZip = "",
|
||||
[string]$WorkDir = "",
|
||||
[ValidateSet("Full", "Smoke", "FailureOnly")]
|
||||
[string]$Mode = "Full",
|
||||
[switch]$KeepWorkDir
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version 2.0
|
||||
|
||||
if (-not $WorkDir) {
|
||||
$WorkDir = Join-Path $env:TEMP ("ga-desktop-e2e\run-" + $RunId)
|
||||
}
|
||||
|
||||
$ScriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$WorkDir = [System.IO.Path]::GetFullPath($WorkDir)
|
||||
$DownloadDir = Join-Path $WorkDir "download"
|
||||
$ExtractDir = Join-Path $WorkDir "app"
|
||||
$ReportDir = Join-Path $WorkDir "report"
|
||||
$ScreensDir = Join-Path $ReportDir "screenshots"
|
||||
$ReportPath = Join-Path $ReportDir "e2e-report.json"
|
||||
$RunSucceeded = $false
|
||||
$PortWasFreeAtStart = $false
|
||||
$UserHome = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)
|
||||
$SettingsPath = Join-Path $UserHome ".ga_desktop_settings.json"
|
||||
$SettingsExisted = Test-Path -LiteralPath $SettingsPath
|
||||
[byte[]]$SettingsBackupBytes = @()
|
||||
$SettingsAttributes = $null
|
||||
if ($SettingsExisted) {
|
||||
$SettingsBackupBytes = [System.IO.File]::ReadAllBytes($SettingsPath)
|
||||
$SettingsAttributes = [System.IO.File]::GetAttributes($SettingsPath)
|
||||
}
|
||||
|
||||
$Report = [ordered]@{
|
||||
repo = $Repo
|
||||
runId = $RunId
|
||||
expectedCommit = $ExpectedCommit
|
||||
mode = $Mode
|
||||
package = [ordered]@{}
|
||||
environment = [ordered]@{}
|
||||
checks = [ordered]@{}
|
||||
screenshots = @()
|
||||
manualChecklist = [ordered]@{
|
||||
windowsTitlebarVisible = "manual"
|
||||
exactlyThreeWindowButtons = "manual"
|
||||
titlebarBlankAreaDrags = "manual"
|
||||
buttonAreaDoesNotDrag = "manual"
|
||||
minimizeWorks = "manual"
|
||||
maximizeRestoreWorks = "manual"
|
||||
closeHidesToTray = "manual"
|
||||
sidebarNavHasNoBlankRow = "manual"
|
||||
nativeDirectoryPicker = "manual"
|
||||
shortcutSelfHealsAfterMove = "manual"
|
||||
loadingFallbackAndMainRender = "manual"
|
||||
}
|
||||
failures = @()
|
||||
startedAt = (Get-Date).ToUniversalTime().ToString("o")
|
||||
}
|
||||
|
||||
function Write-Step([string]$Message) {
|
||||
Write-Host ""
|
||||
Write-Host "==> $Message"
|
||||
}
|
||||
|
||||
function Add-Failure([string]$Message) {
|
||||
$script:Report.failures += $Message
|
||||
}
|
||||
|
||||
function Save-Report {
|
||||
$script:Report.completedAt = (Get-Date).ToUniversalTime().ToString("o")
|
||||
$json = $script:Report | ConvertTo-Json -Depth 12
|
||||
[System.IO.Directory]::CreateDirectory($ReportDir) | Out-Null
|
||||
[System.IO.File]::WriteAllText($ReportPath, $json, [System.Text.UTF8Encoding]::new($false))
|
||||
}
|
||||
|
||||
function Fail([string]$Message) {
|
||||
Add-Failure $Message
|
||||
Save-Report
|
||||
throw $Message
|
||||
}
|
||||
|
||||
function Ensure-Dir([string]$Path) {
|
||||
[System.IO.Directory]::CreateDirectory($Path) | Out-Null
|
||||
}
|
||||
|
||||
function Test-Command([string]$Name) {
|
||||
$cmd = Get-Command $Name -ErrorAction SilentlyContinue
|
||||
return $null -ne $cmd
|
||||
}
|
||||
|
||||
function Normalize-Commit([string]$Commit) {
|
||||
return ($Commit -replace "\s", "").ToLowerInvariant()
|
||||
}
|
||||
|
||||
function Test-CommitMatches([string]$Actual, [string]$Expected) {
|
||||
if (-not $Expected) { return $true }
|
||||
$actualNorm = Normalize-Commit $Actual
|
||||
$expectedNorm = Normalize-Commit $Expected
|
||||
return $actualNorm.StartsWith($expectedNorm) -or $expectedNorm.StartsWith($actualNorm)
|
||||
}
|
||||
|
||||
function Get-RunMetadata {
|
||||
if (-not (Test-Command "gh")) {
|
||||
Fail "GitHub CLI 'gh' is required when -PackageZip is not enough to skip run metadata."
|
||||
}
|
||||
$json = & gh run view $RunId --repo $Repo --json headSha,status,conclusion,event,name 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Fail "gh run view failed: $json"
|
||||
}
|
||||
$meta = ($json | Out-String) | ConvertFrom-Json
|
||||
$script:Report.run = $meta
|
||||
if ($ExpectedCommit -and -not (Test-CommitMatches $meta.headSha $ExpectedCommit)) {
|
||||
Fail "Run headSha $($meta.headSha) does not match expected commit $ExpectedCommit"
|
||||
}
|
||||
return $meta
|
||||
}
|
||||
|
||||
function Resolve-PackageZip {
|
||||
Ensure-Dir $DownloadDir
|
||||
if ($PackageZip) {
|
||||
$zipPath = [System.IO.Path]::GetFullPath($PackageZip)
|
||||
if (-not (Test-Path -LiteralPath $zipPath)) {
|
||||
Fail "Package zip not found: $zipPath"
|
||||
}
|
||||
return $zipPath
|
||||
}
|
||||
|
||||
if (-not (Test-Command "gh")) {
|
||||
Fail "GitHub CLI 'gh' is required to download artifacts."
|
||||
}
|
||||
|
||||
Write-Step "Download GitHub Actions artifact"
|
||||
$downloadOutput = & gh run download $RunId --repo $Repo --dir $DownloadDir 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Fail "gh run download failed: $downloadOutput"
|
||||
}
|
||||
|
||||
$zips = @(Get-ChildItem -LiteralPath $DownloadDir -Recurse -Filter "GenericAgent-Desktop-Windows-Portable.zip")
|
||||
if ($zips.Count -ne 1) {
|
||||
Fail "Expected exactly one GenericAgent-Desktop-Windows-Portable.zip under $DownloadDir, found $($zips.Count)"
|
||||
}
|
||||
return $zips[0].FullName
|
||||
}
|
||||
|
||||
function Test-Sha256([string]$ZipPath) {
|
||||
Write-Step "Verify SHA-256"
|
||||
$hash = (Get-FileHash -LiteralPath $ZipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$shaFile = $ZipPath + ".sha256"
|
||||
if (-not (Test-Path -LiteralPath $shaFile)) {
|
||||
$matches = @(Get-ChildItem -LiteralPath (Split-Path -Parent $ZipPath) -Recurse -File | Where-Object {
|
||||
$_.Name -like "*.sha256" -or $_.Name -like "SHA256SUMS*.txt"
|
||||
})
|
||||
if ($matches.Count -eq 1) {
|
||||
$shaFile = $matches[0].FullName
|
||||
}
|
||||
}
|
||||
if (Test-Path -LiteralPath $shaFile) {
|
||||
$expectedText = [System.IO.File]::ReadAllText($shaFile)
|
||||
$expectedHash = ([regex]::Match($expectedText, "[A-Fa-f0-9]{64}")).Value.ToLowerInvariant()
|
||||
if (-not $expectedHash) {
|
||||
Fail "Could not parse SHA-256 from $shaFile"
|
||||
}
|
||||
if ($hash -ne $expectedHash) {
|
||||
Fail "SHA-256 mismatch. actual=$hash expected=$expectedHash"
|
||||
}
|
||||
$script:Report.package.sha256File = $shaFile
|
||||
$script:Report.package.expectedSha256 = $expectedHash
|
||||
} else {
|
||||
Fail "SHA-256 sidecar not found next to $ZipPath"
|
||||
}
|
||||
$script:Report.package.sha256 = $hash
|
||||
}
|
||||
|
||||
function Expand-Package([string]$ZipPath) {
|
||||
Write-Step "Extract package"
|
||||
if (Test-Path -LiteralPath $ExtractDir) {
|
||||
Remove-Item -LiteralPath $ExtractDir -Recurse -Force
|
||||
}
|
||||
Ensure-Dir $ExtractDir
|
||||
Expand-Archive -LiteralPath $ZipPath -DestinationPath $ExtractDir -Force
|
||||
|
||||
$packageRoot = Join-Path $ExtractDir "GenericAgent-Desktop-Windows-Portable"
|
||||
if (-not (Test-Path -LiteralPath $packageRoot)) {
|
||||
$dirs = @(Get-ChildItem -LiteralPath $ExtractDir -Directory)
|
||||
if ($dirs.Count -eq 1) {
|
||||
$packageRoot = $dirs[0].FullName
|
||||
}
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $packageRoot)) {
|
||||
Fail "Could not find extracted package root under $ExtractDir"
|
||||
}
|
||||
return [System.IO.Path]::GetFullPath($packageRoot)
|
||||
}
|
||||
|
||||
function Assert-PackageShape([string]$PackageRoot) {
|
||||
Write-Step "Validate package structure"
|
||||
$required = @(
|
||||
"GenericAgent.exe",
|
||||
"runtime\install_windows.ps1",
|
||||
"runtime\wheels",
|
||||
"runtime\python",
|
||||
"runtime\app\frontends\desktop_bridge.py"
|
||||
)
|
||||
foreach ($rel in $required) {
|
||||
$path = Join-Path $PackageRoot $rel
|
||||
if (-not (Test-Path -LiteralPath $path)) {
|
||||
Fail "Package is missing required path: $rel"
|
||||
}
|
||||
}
|
||||
$script:Report.package.root = $PackageRoot
|
||||
$script:Report.package.exe = Join-Path $PackageRoot "GenericAgent.exe"
|
||||
$script:Report.package.form = "windows-portable-zip"
|
||||
}
|
||||
|
||||
function Get-WindowsEnvironment {
|
||||
$os = Get-CimInstance Win32_OperatingSystem
|
||||
$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1
|
||||
$arch = if ($env:PROCESSOR_ARCHITECTURE) { $env:PROCESSOR_ARCHITECTURE } else { [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() }
|
||||
return [ordered]@{
|
||||
caption = $os.Caption
|
||||
version = $os.Version
|
||||
buildNumber = $os.BuildNumber
|
||||
architecture = $arch
|
||||
cpu = $cpu.Name
|
||||
userInteractive = [Environment]::UserInteractive
|
||||
webView2Installed = Test-WebView2Installed
|
||||
}
|
||||
}
|
||||
|
||||
function Test-WebView2Installed {
|
||||
$keys = @(
|
||||
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}",
|
||||
"HKLM:\SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}",
|
||||
"HKCU:\SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"
|
||||
)
|
||||
foreach ($key in $keys) {
|
||||
if (Test-Path $key) { return $true }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Get-PortState {
|
||||
try {
|
||||
$connections = @(Get-NetTCPConnection -LocalAddress "127.0.0.1" -LocalPort 14168 -ErrorAction SilentlyContinue)
|
||||
return @($connections | Select-Object LocalAddress, LocalPort, State, OwningProcess)
|
||||
} catch {
|
||||
return @()
|
||||
}
|
||||
}
|
||||
|
||||
function Get-GaProcesses {
|
||||
return @(Get-Process | Where-Object {
|
||||
$_.ProcessName -like "GenericAgent*" -or $_.ProcessName -like "ga-desktop*" -or $_.ProcessName -like "python*"
|
||||
} | Select-Object ProcessName, Id, Path)
|
||||
}
|
||||
|
||||
function Stop-ProcessTreeSafe([int]$ProcessId) {
|
||||
try {
|
||||
Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function Capture-Screenshot([string]$Name) {
|
||||
Ensure-Dir $ScreensDir
|
||||
$path = Join-Path $ScreensDir $Name
|
||||
try {
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
|
||||
$bitmap = [System.Drawing.Bitmap]::new($bounds.Width, $bounds.Height)
|
||||
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
$graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
|
||||
$bitmap.Save($path, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$graphics.Dispose()
|
||||
$bitmap.Dispose()
|
||||
$script:Report.screenshots += $path
|
||||
return $path
|
||||
} catch {
|
||||
Add-Failure "Screenshot failed for ${Name}: $($_.Exception.Message)"
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-ForBootstrapPhase(
|
||||
[string]$ReportDirectory,
|
||||
[string]$Phase,
|
||||
[int]$TimeoutSeconds,
|
||||
[int]$AfterSequence = 0
|
||||
) {
|
||||
$latest = Join-Path $ReportDirectory "bootstrap-latest.json"
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if (Test-Path -LiteralPath $latest) {
|
||||
try {
|
||||
$snapshot = [System.IO.File]::ReadAllText($latest) | ConvertFrom-Json
|
||||
if ([int]$snapshot.seq -le $AfterSequence) {
|
||||
Start-Sleep -Milliseconds 500
|
||||
continue
|
||||
}
|
||||
if ($snapshot.phase -eq $Phase) {
|
||||
return $snapshot
|
||||
}
|
||||
if ($Phase -ne "failed" -and $snapshot.phase -eq "failed") {
|
||||
return $snapshot
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Wait-ForBootstrapAnyPhase([string]$ReportDirectory, [string[]]$Phases, [int]$TimeoutSeconds) {
|
||||
$latest = Join-Path $ReportDirectory "bootstrap-latest.json"
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if (Test-Path -LiteralPath $latest) {
|
||||
try {
|
||||
$snapshot = [System.IO.File]::ReadAllText($latest) | ConvertFrom-Json
|
||||
if ($Phases -contains [string]$snapshot.phase) {
|
||||
return $snapshot
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Wait-ForBridgeIdentity([string]$ExpectedRoot, [int]$TimeoutSeconds) {
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
try {
|
||||
$identity = Invoke-RestMethod -Uri "http://127.0.0.1:14168/services/identity" -TimeoutSec 2
|
||||
if ($identity.ga_root) {
|
||||
$reported = [System.IO.Path]::GetFullPath([string]$identity.ga_root).TrimEnd('\')
|
||||
$expected = [System.IO.Path]::GetFullPath($ExpectedRoot).TrimEnd('\')
|
||||
if ($reported.Equals($expected, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
return $identity
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
Start-Sleep -Milliseconds 700
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Start-GenericAgent([string]$PackageRoot, [string]$Scenario) {
|
||||
$exe = Join-Path $PackageRoot "GenericAgent.exe"
|
||||
$scenarioReportDir = Join-Path $ReportDir $Scenario
|
||||
Ensure-Dir $scenarioReportDir
|
||||
$env:GA_DESKTOP_E2E_REPORT_DIR = $scenarioReportDir
|
||||
try {
|
||||
return Start-Process -FilePath $exe -WorkingDirectory $PackageRoot -PassThru
|
||||
} finally {
|
||||
Remove-Item Env:\GA_DESKTOP_E2E_REPORT_DIR -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function Stop-GenericAgent {
|
||||
foreach ($proc in @(Get-Process GenericAgent -ErrorAction SilentlyContinue)) {
|
||||
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
foreach ($proc in @(Get-Process ga-desktop -ErrorAction SilentlyContinue)) {
|
||||
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function Stop-BridgeOnPort {
|
||||
foreach ($conn in @(Get-PortState)) {
|
||||
if ($conn.OwningProcess) {
|
||||
try {
|
||||
$proc = Get-Process -Id $conn.OwningProcess -ErrorAction Stop
|
||||
if ($proc.ProcessName -like "python*" -or $proc.ProcessName -like "GenericAgent*" -or $proc.ProcessName -like "ga-desktop*") {
|
||||
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-FreshLaunch([string]$PackageRoot) {
|
||||
Write-Step "Fresh launch and first-run prepare"
|
||||
$marker = Join-Path $PackageRoot "runtime\.prepared"
|
||||
if (Test-Path -LiteralPath $marker) {
|
||||
Fail "Fresh package already has runtime\\.prepared before launch"
|
||||
}
|
||||
|
||||
$proc = Start-GenericAgent $PackageRoot "fresh"
|
||||
$script:Report.checks.freshProcessId = $proc.Id
|
||||
Start-Sleep -Seconds 2
|
||||
Capture-Screenshot "loading-first.png" | Out-Null
|
||||
$prepSnapshot = Wait-ForBootstrapAnyPhase (Join-Path $ReportDir "fresh") @("preparing", "ready", "failed") 30
|
||||
if ($prepSnapshot -and $prepSnapshot.phase -eq "preparing") {
|
||||
Capture-Screenshot "preparing.png" | Out-Null
|
||||
} else {
|
||||
$prepPhase = if ($prepSnapshot) { [string]$prepSnapshot.phase } else { "unavailable" }
|
||||
$script:Report.checks.preparingScreenshot = "not captured; phase was $prepPhase"
|
||||
}
|
||||
|
||||
$snapshot = Wait-ForBootstrapPhase (Join-Path $ReportDir "fresh") "ready" 240
|
||||
if (-not $snapshot -or $snapshot.phase -ne "ready") {
|
||||
Capture-Screenshot "fresh-failed.png" | Out-Null
|
||||
Fail "Fresh launch did not reach bootstrap ready"
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $marker)) {
|
||||
Fail "Fresh launch reached ready but runtime\\.prepared was not created"
|
||||
}
|
||||
$identity = Wait-ForBridgeIdentity (Join-Path $PackageRoot "runtime\app") 30
|
||||
if (-not $identity) {
|
||||
Fail "Bridge identity did not match extracted runtime app"
|
||||
}
|
||||
Capture-Screenshot "main-ready.png" | Out-Null
|
||||
$script:Report.checks.freshReady = $true
|
||||
$script:Report.checks.bridgeIdentity = $identity
|
||||
}
|
||||
|
||||
function Start-ForeignPortListener {
|
||||
Write-Step "Start unknown foreign listener on 127.0.0.1:14168"
|
||||
$ready = Join-Path $WorkDir "foreign-listener.ready"
|
||||
Remove-Item -LiteralPath $ready -Force -ErrorAction SilentlyContinue
|
||||
$fixture = Join-Path $ScriptRoot "fixtures\foreign-port-listener.ps1"
|
||||
$proc = Start-Process -FilePath "powershell.exe" -ArgumentList @(
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy", "Bypass",
|
||||
"-File", $fixture,
|
||||
"-Port", "14168",
|
||||
"-ReadyFile", $ready
|
||||
) -WindowStyle Hidden -PassThru
|
||||
$deadline = (Get-Date).AddSeconds(20)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if ((Test-Path -LiteralPath $ready) -and (Get-PortState).Count -gt 0) {
|
||||
return $proc
|
||||
}
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
Stop-ProcessTreeSafe $proc.Id
|
||||
Fail "Foreign listener did not bind 127.0.0.1:14168"
|
||||
}
|
||||
|
||||
function Invoke-SetupRetry(
|
||||
[System.Diagnostics.Process]$AppProcess,
|
||||
[int]$TimeoutSeconds,
|
||||
[int]$AfterSequence
|
||||
) {
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
$sig = @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class Win32Focus {
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
||||
}
|
||||
"@
|
||||
Add-Type -TypeDefinition $sig -ErrorAction SilentlyContinue
|
||||
try {
|
||||
$AppProcess.Refresh()
|
||||
if ($AppProcess.MainWindowHandle -ne [IntPtr]::Zero) {
|
||||
[Win32Focus]::ShowWindow($AppProcess.MainWindowHandle, 9) | Out-Null
|
||||
[Win32Focus]::SetForegroundWindow($AppProcess.MainWindowHandle) | Out-Null
|
||||
Start-Sleep -Milliseconds 500
|
||||
[System.Windows.Forms.SendKeys]::SendWait("{TAB}{TAB}{ENTER}")
|
||||
}
|
||||
} catch {
|
||||
Add-Failure "Automatic setup retry keypress failed: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
$snapshot = Wait-ForBootstrapPhase `
|
||||
(Join-Path $ReportDir "port-conflict") "ready" $TimeoutSeconds $AfterSequence
|
||||
if ($snapshot -and $snapshot.phase -eq "ready") {
|
||||
$script:Report.checks.setupRetry = "automatic"
|
||||
return $true
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Manual action required: click 'Retry startup' in the setup window."
|
||||
$snapshot = Wait-ForBootstrapPhase `
|
||||
(Join-Path $ReportDir "port-conflict") "ready" 180 $AfterSequence
|
||||
if ($snapshot -and $snapshot.phase -eq "ready") {
|
||||
$script:Report.checks.setupRetry = "manual"
|
||||
return $true
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Invoke-PortConflictScenario([string]$PackageRoot) {
|
||||
Write-Step "Port conflict recovery scenario"
|
||||
Stop-GenericAgent
|
||||
Stop-BridgeOnPort
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
$listener = Start-ForeignPortListener
|
||||
$script:Report.checks.foreignListenerPid = $listener.Id
|
||||
$appProc = Start-GenericAgent $PackageRoot "port-conflict"
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
$failed = Wait-ForBootstrapPhase (Join-Path $ReportDir "port-conflict") "failed" 80
|
||||
if (-not $failed -or $failed.phase -ne "failed") {
|
||||
Stop-ProcessTreeSafe $listener.Id
|
||||
Fail "Port conflict scenario did not reach failed phase"
|
||||
}
|
||||
if ($failed.failure.code -ne "port_conflict") {
|
||||
Stop-ProcessTreeSafe $listener.Id
|
||||
Fail "Expected port_conflict, got $($failed.failure.code)"
|
||||
}
|
||||
try {
|
||||
Get-Process -Id $listener.Id -ErrorAction Stop | Out-Null
|
||||
$script:Report.checks.foreignListenerSurvived = $true
|
||||
} catch {
|
||||
Fail "Foreign listener was not alive after port_conflict"
|
||||
}
|
||||
Capture-Screenshot "setup-failure.png" | Out-Null
|
||||
|
||||
Stop-ProcessTreeSafe $listener.Id
|
||||
Start-Sleep -Seconds 2
|
||||
if (-not (Invoke-SetupRetry $appProc 40 ([int]$failed.seq))) {
|
||||
Fail "Setup retry did not reach ready after releasing port"
|
||||
}
|
||||
Capture-Screenshot "setup-retry-ready.png" | Out-Null
|
||||
$script:Report.checks.portConflictRecovery = $true
|
||||
}
|
||||
|
||||
function Invoke-ProductionContractJourney(
|
||||
[string]$PackageRoot,
|
||||
[string]$ZipPath
|
||||
) {
|
||||
Write-Step "Run production package chat, data, relocation, and fallback journey"
|
||||
if (-not $ExpectedCommit) {
|
||||
Fail "Full mode requires -ExpectedCommit for the production build identity assertion"
|
||||
}
|
||||
Stop-GenericAgent
|
||||
Stop-BridgeOnPort
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
# The package itself must be movable while the journey is running. Running the driver
|
||||
# with runtime\python\python.exe would keep that executable open and make Windows reject
|
||||
# the relocation, so copy the already-prepared embedded interpreter outside the package.
|
||||
$driverPythonRoot = Join-Path $WorkDir "production-contract-python"
|
||||
if (Test-Path -LiteralPath $driverPythonRoot) {
|
||||
Remove-Item -LiteralPath $driverPythonRoot -Recurse -Force
|
||||
}
|
||||
Copy-Item -LiteralPath (Join-Path $PackageRoot "runtime\python") `
|
||||
-Destination $driverPythonRoot -Recurse -Force
|
||||
$python = Join-Path $driverPythonRoot "python.exe"
|
||||
if (-not (Test-Path -LiteralPath $python)) {
|
||||
Fail "Could not create an out-of-package Python for the relocation journey"
|
||||
}
|
||||
$driver = [System.IO.Path]::GetFullPath((Join-Path $ScriptRoot "..\package\real_package_journey.py"))
|
||||
$contractReport = Join-Path $ReportDir "production-contract"
|
||||
$contractWork = Join-Path $WorkDir "production-contract-work"
|
||||
$relocated = Join-Path $ExtractDir "relocated\含 空格\GenericAgent 包"
|
||||
& $python $driver `
|
||||
--platform windows `
|
||||
--artifact $ZipPath `
|
||||
--expected-commit $ExpectedCommit `
|
||||
--package-root $PackageRoot `
|
||||
--application-relative "GenericAgent.exe" `
|
||||
--runtime-relative "runtime" `
|
||||
--relocated-root $relocated `
|
||||
--report-dir $contractReport `
|
||||
--work-dir $contractWork `
|
||||
--allow-user-settings-mutation `
|
||||
--screenshots-optional
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Fail "Production package contract journey failed; see $contractReport"
|
||||
}
|
||||
$script:Report.checks.productionContract = [System.IO.File]::ReadAllText(
|
||||
(Join-Path $contractReport "real-package-report.json")
|
||||
) | ConvertFrom-Json
|
||||
}
|
||||
|
||||
try {
|
||||
Ensure-Dir $WorkDir
|
||||
Ensure-Dir $ReportDir
|
||||
Ensure-Dir $ScreensDir
|
||||
$Report.environment = Get-WindowsEnvironment
|
||||
|
||||
if (-not $PackageZip) {
|
||||
Write-Step "Read GitHub Actions run metadata"
|
||||
Get-RunMetadata | Out-Null
|
||||
} elseif ($ExpectedCommit) {
|
||||
$Report.run = [ordered]@{ headSha = $ExpectedCommit; source = "local-package" }
|
||||
}
|
||||
|
||||
$zip = Resolve-PackageZip
|
||||
$Report.package.zip = $zip
|
||||
Test-Sha256 $zip
|
||||
$packageRoot = Expand-Package $zip
|
||||
Assert-PackageShape $packageRoot
|
||||
$Report.environment.initialPortState = @(Get-PortState)
|
||||
$Report.environment.initialProcesses = @(Get-GaProcesses)
|
||||
if ($Report.environment.initialPortState.Count -gt 0) {
|
||||
Fail "127.0.0.1:14168 was already occupied before the package journey"
|
||||
}
|
||||
$script:PortWasFreeAtStart = $true
|
||||
|
||||
if ($Mode -eq "Smoke" -or $Mode -eq "Full") {
|
||||
Invoke-FreshLaunch $packageRoot
|
||||
}
|
||||
if ($Mode -eq "FailureOnly" -or $Mode -eq "Full") {
|
||||
Invoke-PortConflictScenario $packageRoot
|
||||
}
|
||||
if ($Mode -eq "Full") {
|
||||
Invoke-ProductionContractJourney $packageRoot $zip
|
||||
}
|
||||
|
||||
Stop-GenericAgent
|
||||
Stop-BridgeOnPort
|
||||
Start-Sleep -Seconds 2
|
||||
$Report.environment.finalPortState = @(Get-PortState)
|
||||
$Report.environment.finalProcesses = @(Get-GaProcesses)
|
||||
if ($Report.environment.finalPortState.Count -gt 0) {
|
||||
Fail "Package journey left a listener on 127.0.0.1:14168"
|
||||
}
|
||||
$Report.success = $true
|
||||
$script:RunSucceeded = $true
|
||||
Save-Report
|
||||
Write-Host ""
|
||||
Write-Host "E2E report: $ReportPath"
|
||||
} catch {
|
||||
$Report.success = $false
|
||||
Add-Failure $_.Exception.Message
|
||||
$Report.environment.finalPortState = @(Get-PortState)
|
||||
$Report.environment.finalProcesses = @(Get-GaProcesses)
|
||||
Save-Report
|
||||
Write-Error $_.Exception.Message
|
||||
exit 1
|
||||
} finally {
|
||||
if ($script:PortWasFreeAtStart) {
|
||||
Stop-GenericAgent
|
||||
Stop-BridgeOnPort
|
||||
}
|
||||
if ($SettingsExisted) {
|
||||
[System.IO.File]::WriteAllBytes($SettingsPath, $SettingsBackupBytes)
|
||||
if ($null -ne $SettingsAttributes) {
|
||||
[System.IO.File]::SetAttributes($SettingsPath, $SettingsAttributes)
|
||||
}
|
||||
} else {
|
||||
Remove-Item -LiteralPath $SettingsPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
$script:Report.checks.settingsRestored = if ($SettingsExisted) {
|
||||
[Convert]::ToBase64String([System.IO.File]::ReadAllBytes($SettingsPath)) -eq `
|
||||
[Convert]::ToBase64String($SettingsBackupBytes)
|
||||
} else {
|
||||
-not (Test-Path -LiteralPath $SettingsPath)
|
||||
}
|
||||
Save-Report
|
||||
if ($script:RunSucceeded -and -not $KeepWorkDir) {
|
||||
Remove-Item -LiteralPath $ExtractDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $DownloadDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Write-Host "Report kept at: $ReportDir"
|
||||
} else {
|
||||
Write-Host "WorkDir kept for diagnostics: $WorkDir"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# Windows Desktop E2E
|
||||
|
||||
This folder contains the Windows validation harness for the portable desktop package. It simulates the user path:
|
||||
|
||||
1. Download a GitHub Actions artifact, or use a local zip.
|
||||
2. Verify commit, SHA-256, and required package files.
|
||||
3. Extract to a clean directory.
|
||||
4. Launch `GenericAgent.exe` directly.
|
||||
5. Wait for first-run prepare, bridge identity, and bootstrap `ready`.
|
||||
6. In `Full` mode, inject an unknown process on port `14168`, verify `port_conflict`, release it, and retry from setup.
|
||||
7. Run the shared production-package contract with the embedded Python: package-owned bridge plus
|
||||
external `GA_ROOT`, fake-model chat, upload, memory import, warm restart, a second foreign-port
|
||||
assertion, relocation into a path with spaces and Chinese characters, stale override fallback,
|
||||
optional P2P degradation, and process/settings cleanup.
|
||||
|
||||
## Full Run
|
||||
|
||||
```powershell
|
||||
.\frontends\desktop\e2e\windows\Invoke-WindowsUserJourney.ps1 `
|
||||
-Repo abraxas914/GenericAgent `
|
||||
-RunId 29071095889 `
|
||||
-ExpectedCommit 696ddfc `
|
||||
-Mode Full
|
||||
```
|
||||
|
||||
Use `-PackageZip C:\path\GenericAgent-Desktop-Windows-Portable.zip` when the artifact has expired or has already been downloaded.
|
||||
|
||||
## Modes
|
||||
|
||||
- `Smoke`: package verification, extraction, first launch, prepare marker, bridge identity, bootstrap ready.
|
||||
- `FailureOnly`: assumes the package can be extracted and focuses on the unknown port conflict and setup retry path.
|
||||
- `Full`: runs `Smoke`, the native retry failure path, and the complete production package journey.
|
||||
It requires `-ExpectedCommit` so the bridge build identity is tied to the candidate SHA.
|
||||
|
||||
## Manual Checks
|
||||
|
||||
The script collects screenshots and writes these checklist items to the report for the tester to mark externally:
|
||||
|
||||
- Loading, prepare, setup, and main windows always show the Windows titlebar.
|
||||
- Right side has exactly minimize, maximize, and close.
|
||||
- Blank titlebar area drags the window; button area does not.
|
||||
- Minimize works.
|
||||
- Maximize and restore work.
|
||||
- Close hides to tray instead of exiting.
|
||||
- Sidebar nav sits directly below the custom titlebar with no blank row.
|
||||
- The native directory picker opens and returns a real directory.
|
||||
- The shortcut self-heals after the portable folder is moved.
|
||||
- Loading, fallback, and main React pages render through the Tauri resource protocol.
|
||||
|
||||
## Report
|
||||
|
||||
Reports are written under `<WorkDir>\report`:
|
||||
|
||||
- `e2e-report.json`
|
||||
- `bootstrap-events.jsonl`
|
||||
- `bootstrap-latest.json`
|
||||
- screenshots such as `loading-first.png`, `main-ready.png`, and `setup-failure.png`
|
||||
- `production-contract/real-package-report.json`, including artifact SHA-256, OS/architecture,
|
||||
bootstrap phases, bridge identities, PIDs/ports, before/after paths, deterministic data results,
|
||||
and cleanup state
|
||||
|
||||
Failures exit non-zero and keep diagnostics unless `-KeepWorkDir` is omitted and cleanup succeeds.
|
||||
@@ -0,0 +1,35 @@
|
||||
param(
|
||||
[int]$Port = 14168,
|
||||
[string]$ReadyFile = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version 2.0
|
||||
|
||||
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Parse("127.0.0.1"), $Port)
|
||||
$listener.Start()
|
||||
|
||||
if ($ReadyFile) {
|
||||
$dir = Split-Path -Parent $ReadyFile
|
||||
if ($dir) {
|
||||
[System.IO.Directory]::CreateDirectory($dir) | Out-Null
|
||||
}
|
||||
[System.IO.File]::WriteAllText($ReadyFile, "ready", [System.Text.Encoding]::ASCII)
|
||||
}
|
||||
|
||||
try {
|
||||
while ($true) {
|
||||
$client = $listener.AcceptTcpClient()
|
||||
try {
|
||||
$stream = $client.GetStream()
|
||||
$body = "foreign listener"
|
||||
$response = "HTTP/1.1 404 Not Found`r`nContent-Type: text/plain`r`nContent-Length: $($body.Length)`r`nConnection: close`r`n`r`n$body"
|
||||
$bytes = [System.Text.Encoding]::ASCII.GetBytes($response)
|
||||
$stream.Write($bytes, 0, $bytes.Length)
|
||||
} finally {
|
||||
$client.Close()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$listener.Stop()
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { access, readFile, readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const desktopDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const repoRoot = path.resolve(desktopDir, '..', '..');
|
||||
const distDir = path.join(desktopDir, 'dist');
|
||||
const expectedVersion = '0.2.0';
|
||||
const expectedSourceCommit = '21f12f6dfe57ada8572bb21fe6604caac230b1cf';
|
||||
const expectedNoticeSha256 = '2acf865e87e59090121369aac0575467067fdd7999923a70d785a46ceae3330f';
|
||||
const noticeAttributeRules = [
|
||||
'frontends/desktop/public/THIRD_PARTY_NOTICES.txt text eol=lf',
|
||||
'frontends/desktop/dist/THIRD_PARTY_NOTICES.txt text eol=lf',
|
||||
];
|
||||
const excludedManifestFiles = new Set(['README.md', 'build-provenance.json']);
|
||||
const removedLegacyAssets = [
|
||||
'styles.css',
|
||||
'i18n.js',
|
||||
'phosphor-icons.js',
|
||||
'vendor/marked.min.js',
|
||||
'assets/fonts/fonts.css',
|
||||
'assets/fonts/README.md',
|
||||
'assets/fonts/azonix-wordmark.woff2',
|
||||
'assets/fonts/jetbrains-mono-latin.woff2',
|
||||
'assets/fonts/lexend-latin.woff2',
|
||||
'assets/fonts/noto-sans-latin.woff2',
|
||||
];
|
||||
const semiPackages = [
|
||||
'@douyinfe/semi-animation',
|
||||
'@douyinfe/semi-animation-react',
|
||||
'@douyinfe/semi-animation-styled',
|
||||
'@douyinfe/semi-foundation',
|
||||
'@douyinfe/semi-icons',
|
||||
'@douyinfe/semi-illustrations',
|
||||
'@douyinfe/semi-json-viewer-core',
|
||||
'@douyinfe/semi-theme-default',
|
||||
'@douyinfe/semi-ui',
|
||||
];
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
async function exists(file) {
|
||||
try {
|
||||
await access(file);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readJson(file) {
|
||||
return JSON.parse(await readFile(file, 'utf8'));
|
||||
}
|
||||
|
||||
async function walk(dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...await walk(fullPath));
|
||||
if (entry.isFile()) files.push(fullPath);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function sha256(contents) {
|
||||
return createHash('sha256').update(contents).digest('hex');
|
||||
}
|
||||
|
||||
function relativeToDist(file) {
|
||||
return path.relative(distDir, file).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
async function assertExists(relativePath) {
|
||||
if (!await exists(path.join(distDir, relativePath))) {
|
||||
fail(`missing compiled asset: ${relativePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function workflowJob(workflow, name) {
|
||||
const match = new RegExp(`^ ${name}:\\s*$`, 'm').exec(workflow);
|
||||
if (!match) return '';
|
||||
const remainder = workflow.slice(match.index + match[0].length);
|
||||
const nextJob = /^ [a-zA-Z0-9_-]+:\s*$/m.exec(remainder);
|
||||
return nextJob ? remainder.slice(0, nextJob.index) : remainder;
|
||||
}
|
||||
|
||||
async function verifyHtmlReferences(relativePath) {
|
||||
const html = await readFile(path.join(distDir, relativePath), 'utf8');
|
||||
const references = [...html.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map((match) => match[1]);
|
||||
for (const reference of references) {
|
||||
if (/^(?:[a-z]+:|#|\/\/)/i.test(reference)) continue;
|
||||
const cleanReference = reference.split(/[?#]/, 1)[0].replace(/^\//, '');
|
||||
if (cleanReference) await assertExists(cleanReference);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyTrackedSourceBoundary() {
|
||||
const tracked = execFileSync('git', ['ls-files'], { cwd: repoRoot, encoding: 'utf8' })
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
const forbidden = tracked.filter((file) => (
|
||||
file.startsWith('frontends/desktop/src/')
|
||||
|| file.startsWith('frontends/desktop/public/')
|
||||
|| (file.startsWith('frontends/desktop/e2e/')
|
||||
&& file !== 'frontends/desktop/e2e/requirements.txt'
|
||||
&& !file.startsWith('frontends/desktop/e2e/package/')
|
||||
&& !file.startsWith('frontends/desktop/e2e/linux/')
|
||||
&& !file.startsWith('frontends/desktop/e2e/macos/')
|
||||
&& !file.startsWith('frontends/desktop/e2e/windows/'))
|
||||
|| /^frontends\/desktop\/(?:index|loading|setup)\.html$/.test(file)
|
||||
|| /^frontends\/desktop\/(?:vite\.config\.[^/]+|tsconfig(?:\.[^/]+)?\.json|package-lock\.json)$/.test(file)
|
||||
));
|
||||
if (forbidden.length > 0) {
|
||||
fail(`tracked React development files crossed the compiled-only boundary: ${forbidden.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyNoticeAttributes() {
|
||||
const attributes = await readFile(path.join(repoRoot, '.gitattributes'), 'utf8');
|
||||
const lines = attributes.split(/\r?\n/);
|
||||
for (const rule of noticeAttributeRules) {
|
||||
const noticePath = rule.split(/\s+/, 1)[0];
|
||||
const matchingRules = lines.filter((line) => line.trim().split(/\s+/, 1)[0] === noticePath);
|
||||
if (matchingRules.length !== 1 || matchingRules[0] !== rule) {
|
||||
fail(`third-party notice must have one exact LF attribute rule: ${rule}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyVersionsRendererAndEntry() {
|
||||
const packageJson = await readJson(path.join(desktopDir, 'package.json'));
|
||||
const tauriConfig = await readJson(path.join(desktopDir, 'src-tauri', 'tauri.conf.json'));
|
||||
const tauriE2eConfig = await readJson(path.join(desktopDir, 'src-tauri', 'tauri.e2e.conf.json'));
|
||||
const cargoToml = await readFile(path.join(desktopDir, 'src-tauri', 'Cargo.toml'), 'utf8');
|
||||
const cargoLock = await readFile(path.join(desktopDir, 'src-tauri', 'Cargo.lock'), 'utf8');
|
||||
const desktopShell = await readFile(path.join(desktopDir, 'src-tauri', 'src', 'lib.rs'), 'utf8');
|
||||
|
||||
if (packageJson.version !== expectedVersion) fail(`package.json version must be ${expectedVersion}`);
|
||||
if (tauriConfig.version !== expectedVersion) fail(`tauri.conf.json version must be ${expectedVersion}`);
|
||||
if (tauriConfig.build?.frontendDist !== '../dist') fail('Tauri frontendDist must be ../dist');
|
||||
for (const sourceBuildKey of ['beforeBuildCommand', 'beforeDevCommand', 'devUrl']) {
|
||||
if (sourceBuildKey in tauriConfig.build) fail(`compiled-only Tauri config must not require ${sourceBuildKey}`);
|
||||
}
|
||||
if (!/^\[package\][\s\S]*?^name = "ga-desktop"$[\s\S]*?^version = "0\.2\.0"$/m.test(cargoToml)) {
|
||||
fail(`Cargo.toml ga-desktop version must be ${expectedVersion}`);
|
||||
}
|
||||
if (!/^\[\[package\]\][\s\S]*?^name = "ga-desktop"$\nversion = "0\.2\.0"$/m.test(cargoLock)) {
|
||||
fail(`Cargo.lock ga-desktop version must be ${expectedVersion}`);
|
||||
}
|
||||
|
||||
const productionSecurity = tauriConfig.app?.security;
|
||||
if (!productionSecurity?.csp || typeof productionSecurity.csp !== 'object') {
|
||||
fail('production renderer must use an explicit CSP');
|
||||
}
|
||||
if (String(productionSecurity.csp['script-src']).includes('unsafe-eval')) {
|
||||
fail('production CSP must not enable unsafe-eval');
|
||||
}
|
||||
if (!String(productionSecurity.csp['connect-src']).includes('127.0.0.1:14168')) {
|
||||
fail('production CSP must retain the Desktop loopback bridge');
|
||||
}
|
||||
if (JSON.stringify(productionSecurity).includes('wdio:')) {
|
||||
fail('production Tauri security config must not grant WebDriver permissions');
|
||||
}
|
||||
if (!JSON.stringify(tauriE2eConfig).includes('wdio:default')) {
|
||||
fail('E2E-only Tauri config must retain its isolated WebDriver capability');
|
||||
}
|
||||
if (!desktopShell.includes('fn main_ui_url_from_current')) {
|
||||
fail('desktop shell must resolve index.html from the active Tauri asset origin');
|
||||
}
|
||||
if (/tauri::Url::parse\("http:\/\/127\.0\.0\.1:14168\/?"\)/.test(desktopShell)) {
|
||||
fail('desktop shell must not navigate the renderer to the legacy bridge root');
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyReleaseContract() {
|
||||
const workflow = await readFile(
|
||||
path.join(repoRoot, '.github', 'workflows', 'desktop-release-package.yml'),
|
||||
'utf8',
|
||||
);
|
||||
const buildJobs = ['build-windows', 'build-linux', 'build-macos'];
|
||||
const tauriCommands = {
|
||||
'build-windows': 'npm run tauri build -- --bundles nsis',
|
||||
'build-linux': 'npm run tauri build -- --bundles appimage',
|
||||
'build-macos': 'npm run tauri build -- --bundles app',
|
||||
};
|
||||
const jobNames = [...workflow.slice(workflow.search(/^jobs:\s*$/m)).matchAll(/^ ([a-zA-Z0-9_-]+):\s*$/gm)]
|
||||
.map((match) => match[1]);
|
||||
if (JSON.stringify(jobNames) !== JSON.stringify([...buildJobs, 'publish-release'])) {
|
||||
fail('release workflow must contain exactly three builders and one publisher');
|
||||
}
|
||||
if (!/^permissions:\n contents: read\s*$/m.test(workflow)) {
|
||||
fail('release workflow must default to contents: read');
|
||||
}
|
||||
if ((workflow.match(/^ contents: write\s*$/gm) ?? []).length !== 1) {
|
||||
fail('release workflow must grant contents: write exactly once');
|
||||
}
|
||||
if ((workflow.match(/^ run: npm run test:dist$/gm) ?? []).length !== 3) {
|
||||
fail('all three builders must run the compiled renderer byte contract');
|
||||
}
|
||||
if (/\bnpm ci\b|\bnpm run build\b|\b(?:npx|npm exec)\s+vite\b/.test(workflow)) {
|
||||
fail('compiled-only release workflow must not require the React source toolchain');
|
||||
}
|
||||
|
||||
for (const job of buildJobs) {
|
||||
const body = workflowJob(workflow, job);
|
||||
if (!body) fail(`missing release builder: ${job}`);
|
||||
if (!/^ permissions:\n contents: read\s*$/m.test(body) || body.includes('contents: write')) {
|
||||
fail(`${job} must have read-only repository permission`);
|
||||
}
|
||||
if (!/uses: actions\/checkout@[0-9a-f]{40}[^\n]*\n with:\n persist-credentials: false/.test(body)) {
|
||||
fail(`${job} checkout must use a full SHA and disable persisted credentials`);
|
||||
}
|
||||
if (body.includes('secrets.GITHUB_TOKEN') || /\bgh release\b/.test(body)) {
|
||||
fail(`${job} must not receive a release token or publish`);
|
||||
}
|
||||
if (!body.includes('npm install --package-lock=false')
|
||||
|| !body.includes("if(v!=='2.11.4')")
|
||||
|| !body.includes('test ! -e package-lock.json')
|
||||
|| body.includes('npm ci')) {
|
||||
fail(`${job} must install only the submitted minimal Tauri package manifest`);
|
||||
}
|
||||
const adjacentGate = new RegExp(
|
||||
' - name: Verify tracked compiled renderer bytes\\n'
|
||||
+ ' working-directory: frontends/desktop\\n'
|
||||
+ ' run: npm run test:dist\\n\\n'
|
||||
+ ' - name: [^\\n]+\\n'
|
||||
+ ' working-directory: frontends/desktop\\n'
|
||||
+ ` run: ${tauriCommands[job].replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`,
|
||||
);
|
||||
if (!adjacentGate.test(body)) {
|
||||
fail(`${job} must validate notice bytes immediately before Tauri embeds dist`);
|
||||
}
|
||||
if (!(body.includes("--exclude='./frontends/desktop/dist'")
|
||||
|| body.includes("--exclude='frontends/desktop/dist'"))) {
|
||||
fail(`${job} must not duplicate Tauri-embedded dist inside runtime/app`);
|
||||
}
|
||||
if (!(body.includes('test ! -e "$RUNTIME/app/frontends/desktop/dist"')
|
||||
|| body.includes('test ! -e "$RUNTIME_SRC/app/frontends/desktop/dist"'))) {
|
||||
fail(`${job} must verify dist is not duplicated inside runtime/app`);
|
||||
}
|
||||
}
|
||||
|
||||
const windows = workflowJob(workflow, 'build-windows');
|
||||
if (!windows.includes('cygpath -u "$RUNNER_TEMP"')
|
||||
|| !windows.includes('PBS_ARCHIVE="${RUNNER_TEMP_POSIX}/pbs-windows-x86_64.tar.gz"')) {
|
||||
fail('Windows packaging must convert RUNNER_TEMP with cygpath before POSIX tools use it');
|
||||
}
|
||||
const macos = workflowJob(workflow, 'build-macos');
|
||||
if (!macos.includes('runs-on: macos-15')
|
||||
|| !macos.includes('test "$(uname -m)" = arm64')
|
||||
|| !workflow.includes('MACOS_PACKAGING_PYTHON_VERSION: "3.12.10"')
|
||||
|| !workflow.includes('PBS_PYTHON_VERSION: "3.12.14"')) {
|
||||
fail('macOS packaging must use the pinned arm64 host and separate Python inputs');
|
||||
}
|
||||
const linux = workflowJob(workflow, 'build-linux');
|
||||
if (!linux.includes('runs-on: ubuntu-22.04')
|
||||
|| !linux.includes('prefix-key: "v1-rust-release-ubuntu-22.04-glibc-2.35"')
|
||||
|| linux.includes('prefix-key: "v0-rust"')
|
||||
|| !linux.includes('cache-targets: "false"')
|
||||
|| !linux.includes('cache-bin: "false"')) {
|
||||
fail('Linux packaging must isolate its Ubuntu 22.04/glibc 2.35 Rust cache ABI');
|
||||
}
|
||||
|
||||
const publisher = workflowJob(workflow, 'publish-release');
|
||||
if (!publisher.includes('needs: [build-windows, build-linux, build-macos]')
|
||||
|| !publisher.includes("github.event_name == 'push'")
|
||||
|| !publisher.includes('refs/tags/desktop-portable-')
|
||||
|| !/^ permissions:\n contents: write\s*$/m.test(publisher)) {
|
||||
fail('publisher must be the sole tag-only writer after all three builders');
|
||||
}
|
||||
const publisherRun = publisher.slice(publisher.indexOf(' run: |'));
|
||||
if (!publisher.includes('TAG_NAME: ${{ github.ref_name }}')
|
||||
|| publisherRun.includes('${{ github.ref_name }}')
|
||||
|| !publisherRun.includes('^desktop-portable-[A-Za-z0-9._-]+$')) {
|
||||
fail('publisher must pass the tag through env and validate it before shell use');
|
||||
}
|
||||
if ((publisher.match(/actions\/download-artifact@/g) ?? []).length !== 3
|
||||
|| (publisher.match(/\bgh release create\b/g) ?? []).length !== 1
|
||||
|| publisher.includes('gh release upload')
|
||||
|| !publisher.includes('--draft')
|
||||
|| !publisher.includes('gh release edit "$TAG_NAME" --draft=false --prerelease')) {
|
||||
fail('publisher must aggregate three artifacts as a verified draft, then expose one prerelease');
|
||||
}
|
||||
for (const file of [
|
||||
'GenericAgent-Desktop-Windows-Portable.zip',
|
||||
'SHA256SUMS-windows.txt',
|
||||
'GenericAgent-Desktop-Linux-Portable.tar.gz',
|
||||
'SHA256SUMS-linux.txt',
|
||||
'GenericAgent-Desktop-macOS-aarch64.dmg',
|
||||
'GenericAgent-Desktop-macOS-aarch64.dmg.sha256',
|
||||
]) {
|
||||
if (!publisher.includes(file)) fail(`publisher does not require release asset: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyGeneratedManifest(files) {
|
||||
const provenance = await readJson(path.join(distDir, 'build-provenance.json'));
|
||||
const generatedFiles = files
|
||||
.map((file) => ({ file, relativePath: relativeToDist(file) }))
|
||||
.filter(({ relativePath }) => !excludedManifestFiles.has(relativePath))
|
||||
.sort((left, right) => left.relativePath.localeCompare(right.relativePath, 'en'));
|
||||
|
||||
const manifestLines = [];
|
||||
for (const { file, relativePath } of generatedFiles) {
|
||||
manifestLines.push(`${sha256(await readFile(file))} ${relativePath}\n`);
|
||||
}
|
||||
const manifestDigest = sha256(manifestLines.join(''));
|
||||
|
||||
if (provenance.version !== expectedVersion) fail(`provenance version must be ${expectedVersion}`);
|
||||
if (provenance.sourceRepository !== 'https://github.com/abraxas914/GenericAgent') {
|
||||
fail('provenance source repository is incorrect');
|
||||
}
|
||||
if (provenance.sourceCommit !== expectedSourceCommit) fail('provenance source commit is incorrect');
|
||||
if (provenance.generatedAssetCount !== generatedFiles.length) {
|
||||
fail(`provenance asset count mismatch: expected ${provenance.generatedAssetCount}, found ${generatedFiles.length}`);
|
||||
}
|
||||
if (provenance.generatedManifestSha256 !== manifestDigest) {
|
||||
fail(`provenance manifest mismatch: expected ${provenance.generatedManifestSha256}, found ${manifestDigest}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const requiredFiles = [
|
||||
'index.html',
|
||||
'loading.html',
|
||||
'setup.html',
|
||||
'fallback.html',
|
||||
'assets/ga-logo.svg',
|
||||
'THIRD_PARTY_NOTICES.txt',
|
||||
'README.md',
|
||||
'build-provenance.json',
|
||||
];
|
||||
await Promise.all(requiredFiles.map(assertExists));
|
||||
for (const deadAsset of removedLegacyAssets) {
|
||||
if (await exists(path.join(distDir, deadAsset))) {
|
||||
fail(`dead legacy React asset remains in compiled output: ${deadAsset}`);
|
||||
}
|
||||
}
|
||||
|
||||
verifyTrackedSourceBoundary();
|
||||
await verifyNoticeAttributes();
|
||||
const files = await walk(distDir);
|
||||
const forbiddenSource = files.map(relativeToDist).filter((file) => /(?:\.map|\.tsx?|\.jsx)$/i.test(file));
|
||||
if (forbiddenSource.length > 0) fail(`compiled distribution contains source files: ${forbiddenSource.join(', ')}`);
|
||||
|
||||
const searchableFiles = files.filter((file) => /\.(?:html|js|css)$/i.test(file));
|
||||
for (const file of searchableFiles) {
|
||||
const contents = await readFile(file, 'utf8');
|
||||
if (/(?:webdriverio|__GA_E2E__|wdio:|sourceMappingURL|webpack:\/\/|\/Users\/)/i.test(contents)) {
|
||||
fail(`compiled distribution leaks source/E2E material: ${relativeToDist(file)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const notice = await readFile(path.join(distDir, 'THIRD_PARTY_NOTICES.txt'));
|
||||
if (sha256(notice) !== expectedNoticeSha256) fail('Semi Design third-party notice hash is incorrect');
|
||||
const readme = await readFile(path.join(distDir, 'README.md'), 'utf8');
|
||||
for (const value of [
|
||||
expectedSourceCommit,
|
||||
...semiPackages,
|
||||
'https://github.com/DouyinFE/semi-design',
|
||||
'https://semi.design',
|
||||
'Copyright (c) 2021 DouyinFE',
|
||||
'License: MIT',
|
||||
'THIRD_PARTY_NOTICES.txt',
|
||||
'5ddf03bb152666637bdfcfa44f1fac3cff5a66b6',
|
||||
'2fb55d944e4444b08cb9ad76c13aef7a5788186b',
|
||||
'3e7ca6a2b20eefdb3ee335dc0d520c3b6d9d57f8',
|
||||
'not a claim that later builds are bit-for-bit reproducible',
|
||||
]) {
|
||||
if (!readme.includes(value)) fail(`compiled README is missing required provenance/license text: ${value}`);
|
||||
}
|
||||
|
||||
await Promise.all(['index.html', 'loading.html', 'setup.html'].map(verifyHtmlReferences));
|
||||
await verifyVersionsRendererAndEntry();
|
||||
await verifyReleaseContract();
|
||||
await verifyGeneratedManifest(files);
|
||||
console.log('Compiled React Desktop 2.0 distribution contracts passed.');
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Security and one-shot semantics for the bridge E2E control plane."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
SOURCE = (ROOT / "frontends" / "desktop_bridge.py").read_text(encoding="utf-8")
|
||||
TREE = ast.parse(SOURCE)
|
||||
|
||||
|
||||
def _function_node(name: str) -> ast.FunctionDef | ast.AsyncFunctionDef:
|
||||
return next(
|
||||
node
|
||||
for node in TREE.body
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name
|
||||
)
|
||||
|
||||
|
||||
def _load_helpers():
|
||||
wanted = {
|
||||
"_E2E_NEXT_TURN",
|
||||
"_E2E_CONTROL_LOCK",
|
||||
"_e2e_control_token",
|
||||
"_set_e2e_next_turn",
|
||||
"_consume_e2e_next_turn",
|
||||
}
|
||||
nodes = []
|
||||
for node in TREE.body:
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in wanted:
|
||||
nodes.append(node)
|
||||
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
|
||||
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
||||
names = {target.id for target in targets if isinstance(target, ast.Name)}
|
||||
if names & wanted:
|
||||
nodes.append(node)
|
||||
namespace = {"os": os, "threading": threading}
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), "desktop_bridge.py", "exec"), namespace)
|
||||
return namespace
|
||||
|
||||
|
||||
def test_control_requires_explicit_e2e_flag_and_random_token(monkeypatch):
|
||||
helpers = _load_helpers()
|
||||
monkeypatch.delenv("GA_E2E", raising=False)
|
||||
monkeypatch.setenv("GA_E2E_CONTROL_TOKEN", "secret")
|
||||
assert helpers["_e2e_control_token"]() is None
|
||||
|
||||
monkeypatch.setenv("GA_E2E", "1")
|
||||
monkeypatch.delenv("GA_E2E_CONTROL_TOKEN", raising=False)
|
||||
assert helpers["_e2e_control_token"]() is None
|
||||
|
||||
monkeypatch.setenv("GA_E2E_CONTROL_TOKEN", "secret")
|
||||
assert helpers["_e2e_control_token"]() == "secret"
|
||||
|
||||
|
||||
def test_next_turn_override_is_validated_and_consumed_once():
|
||||
helpers = _load_helpers()
|
||||
with pytest.raises(ValueError, match="empty"):
|
||||
helpers["_set_e2e_next_turn"]("crash")
|
||||
|
||||
helpers["_set_e2e_next_turn"]("empty")
|
||||
assert helpers["_consume_e2e_next_turn"]() == "empty"
|
||||
assert helpers["_consume_e2e_next_turn"]() is None
|
||||
|
||||
|
||||
def test_e2e_route_is_not_registered_without_the_control_gate():
|
||||
create_app = _function_node("create_app")
|
||||
registrations = [
|
||||
node
|
||||
for node in ast.walk(create_app)
|
||||
if isinstance(node, ast.Call)
|
||||
and any(
|
||||
isinstance(arg, ast.Constant) and arg.value == "/__e2e__/next-turn"
|
||||
for arg in node.args
|
||||
)
|
||||
]
|
||||
assert len(registrations) == 1
|
||||
|
||||
registration = registrations[0]
|
||||
gate = next(
|
||||
node
|
||||
for node in ast.walk(create_app)
|
||||
if isinstance(node, ast.If) and registration in list(ast.walk(node))
|
||||
)
|
||||
assert isinstance(gate.test, ast.Compare)
|
||||
assert isinstance(gate.test.left, ast.Call)
|
||||
assert isinstance(gate.test.left.func, ast.Name)
|
||||
assert gate.test.left.func.id == "_e2e_control_token"
|
||||
|
||||
|
||||
def test_e2e_handler_requires_loopback_and_secret_header():
|
||||
handler = _function_node("e2e_next_turn_handler")
|
||||
called_names = {
|
||||
node.func.id
|
||||
for node in ast.walk(handler)
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
|
||||
}
|
||||
constants = {
|
||||
node.value
|
||||
for node in ast.walk(handler)
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str)
|
||||
}
|
||||
assert "_e2e_control_token" in called_names
|
||||
assert "_is_local_peer" in called_names
|
||||
assert "X-GA-E2E-Token" in constants
|
||||
assert any(
|
||||
isinstance(node, ast.Attribute)
|
||||
and isinstance(node.value, ast.Name)
|
||||
and node.value.id == "web"
|
||||
and node.attr == "HTTPNotFound"
|
||||
for node in ast.walk(handler)
|
||||
)
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Unit tests for model hot-switching within a session.
|
||||
|
||||
Tests the contract: set_session_model changes llm_no without destroying session
|
||||
state. Idle agents switch immediately; running turns defer the new client.
|
||||
Run: pytest frontends/tests/test_bridge_model_switch.py -v
|
||||
"""
|
||||
|
||||
|
||||
class FakeAgent:
|
||||
"""Minimal mock of an agent object with next_llm capability."""
|
||||
def __init__(self):
|
||||
self.current_llm = 0
|
||||
self.next_llm_calls = []
|
||||
|
||||
def next_llm(self, llm_no: int):
|
||||
self.next_llm_calls.append(llm_no)
|
||||
self.current_llm = llm_no
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""Minimal mock of desktop_bridge.Session for model switch tests."""
|
||||
def __init__(self, sid="test-session", llm_no=0, messages=None, agent=None):
|
||||
self.id = sid
|
||||
self.llm_no = llm_no
|
||||
self.messages = messages or [
|
||||
{"id": 1, "role": "user", "content": "Hello"},
|
||||
{"id": 2, "role": "assistant", "content": "Hi there"},
|
||||
{"id": 3, "role": "user", "content": "Switch model now"},
|
||||
]
|
||||
self.agent = agent
|
||||
self.status = "idle"
|
||||
self.updated_at = 0.0
|
||||
|
||||
|
||||
def simulate_set_session_model(sess: FakeSession, llm_no: int) -> dict:
|
||||
"""Replicate set_session_model logic from desktop_bridge.py:1105-1118."""
|
||||
import time
|
||||
sess.llm_no = int(llm_no)
|
||||
if sess.status != "running" and sess.agent is not None and hasattr(sess.agent, "next_llm"):
|
||||
try:
|
||||
sess.agent.next_llm(int(llm_no))
|
||||
except Exception:
|
||||
pass
|
||||
sess.updated_at = time.time()
|
||||
return {"ok": True, "sessionId": sess.id, "llmNo": sess.llm_no}
|
||||
|
||||
|
||||
class TestModelHotSwitch:
|
||||
"""Core contract: model switch preserves session state."""
|
||||
|
||||
def test_llm_no_updated(self):
|
||||
sess = FakeSession(llm_no=0)
|
||||
result = simulate_set_session_model(sess, 2)
|
||||
assert sess.llm_no == 2
|
||||
assert result["llmNo"] == 2
|
||||
assert result["ok"] is True
|
||||
|
||||
def test_messages_preserved(self):
|
||||
original_messages = [
|
||||
{"id": 1, "role": "user", "content": "Hello"},
|
||||
{"id": 2, "role": "assistant", "content": "World"},
|
||||
]
|
||||
sess = FakeSession(messages=list(original_messages))
|
||||
simulate_set_session_model(sess, 3)
|
||||
assert sess.messages == original_messages
|
||||
assert len(sess.messages) == 2
|
||||
|
||||
def test_agent_next_llm_called(self):
|
||||
agent = FakeAgent()
|
||||
sess = FakeSession(agent=agent)
|
||||
simulate_set_session_model(sess, 5)
|
||||
assert agent.next_llm_calls == [5]
|
||||
assert agent.current_llm == 5
|
||||
|
||||
def test_agent_none_no_error(self):
|
||||
sess = FakeSession(agent=None)
|
||||
result = simulate_set_session_model(sess, 1)
|
||||
assert result["ok"] is True
|
||||
assert sess.llm_no == 1
|
||||
|
||||
def test_agent_without_next_llm_no_error(self):
|
||||
class LegacyAgent:
|
||||
pass
|
||||
sess = FakeSession(agent=LegacyAgent())
|
||||
result = simulate_set_session_model(sess, 4)
|
||||
assert result["ok"] is True
|
||||
assert sess.llm_no == 4
|
||||
|
||||
def test_updated_at_changed(self):
|
||||
sess = FakeSession()
|
||||
sess.updated_at = 0.0
|
||||
simulate_set_session_model(sess, 1)
|
||||
assert sess.updated_at > 0.0
|
||||
|
||||
def test_status_not_changed(self):
|
||||
sess = FakeSession()
|
||||
sess.status = "idle"
|
||||
simulate_set_session_model(sess, 2)
|
||||
assert sess.status == "idle"
|
||||
|
||||
def test_switch_during_running_preserves_status(self):
|
||||
agent = FakeAgent()
|
||||
sess = FakeSession(agent=agent)
|
||||
sess.status = "running"
|
||||
simulate_set_session_model(sess, 3)
|
||||
assert sess.status == "running"
|
||||
assert sess.llm_no == 3
|
||||
assert agent.next_llm_calls == []
|
||||
|
||||
def test_multiple_switches(self):
|
||||
agent = FakeAgent()
|
||||
sess = FakeSession(agent=agent, llm_no=0)
|
||||
simulate_set_session_model(sess, 1)
|
||||
simulate_set_session_model(sess, 2)
|
||||
simulate_set_session_model(sess, 0)
|
||||
assert sess.llm_no == 0
|
||||
assert agent.next_llm_calls == [1, 2, 0]
|
||||
|
||||
def test_switch_back_to_same_model(self):
|
||||
agent = FakeAgent()
|
||||
sess = FakeSession(agent=agent, llm_no=3)
|
||||
simulate_set_session_model(sess, 3)
|
||||
assert sess.llm_no == 3
|
||||
assert agent.next_llm_calls == [3]
|
||||
|
||||
|
||||
class TestModelSwitchFrontendContract:
|
||||
"""Frontend side: sendPrompt uses current selectedModelNo."""
|
||||
|
||||
def test_llm_no_passed_to_send_prompt(self):
|
||||
"""sendPrompt reads selectedModelNo at call time, not at session creation."""
|
||||
selectedModelNo = 0
|
||||
calls = []
|
||||
|
||||
def mock_send_prompt(sid, prompt, llm_no, files=None, images=None):
|
||||
calls.append({"sid": sid, "llm_no": llm_no})
|
||||
|
||||
# First message with model 0
|
||||
mock_send_prompt("s1", "hello", selectedModelNo)
|
||||
# User switches model
|
||||
selectedModelNo = 2
|
||||
# Second message with model 2
|
||||
mock_send_prompt("s1", "world", selectedModelNo)
|
||||
|
||||
assert calls[0]["llm_no"] == 0
|
||||
assert calls[1]["llm_no"] == 2
|
||||
# Same session ID — context preserved
|
||||
assert calls[0]["sid"] == calls[1]["sid"]
|
||||
|
||||
def test_model_change_does_not_create_new_session(self):
|
||||
"""Switching model mid-session does not call createSession."""
|
||||
session_creates = []
|
||||
|
||||
def mock_create():
|
||||
session_creates.append(1)
|
||||
return "new-session"
|
||||
|
||||
# Simulate: active session exists, user switches model
|
||||
active_session_id = "existing-session"
|
||||
# selectModel only updates selectedModelNo + calls legacy bridge
|
||||
# It does NOT create a new session
|
||||
assert len(session_creates) == 0
|
||||
|
||||
def test_live_model_cleared_on_switch(self):
|
||||
"""selectModel sets liveModel to null (re-fetched from bridge on next poll)."""
|
||||
state = {"selectedModelNo": 0, "liveModel": {"isMixin": False, "current": "gpt-4"}}
|
||||
|
||||
# Simulate selectModel logic
|
||||
state["selectedModelNo"] = 2
|
||||
state["liveModel"] = None # cleared
|
||||
|
||||
assert state["liveModel"] is None
|
||||
assert state["selectedModelNo"] == 2
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Origin enforcement for the local Desktop bridge transport."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from aiohttp import WSServerHandshakeError, web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from frontends.tests.test_bridge_sessions import _mod as bridge
|
||||
|
||||
|
||||
PROTECTED_PATHS = (
|
||||
"/status",
|
||||
"/memory/export",
|
||||
"/memory/import/inspect",
|
||||
"/upload",
|
||||
"/upload/raw",
|
||||
)
|
||||
|
||||
|
||||
async def _with_client(callback, tmp_path: Path):
|
||||
side_effect = tmp_path / "request-reached-handler"
|
||||
|
||||
async def protected_handler(_request):
|
||||
side_effect.write_text("reached", encoding="utf-8")
|
||||
return web.Response(text="protected-secret")
|
||||
|
||||
async def http_error(_request):
|
||||
raise web.HTTPBadRequest(text="bad request")
|
||||
|
||||
async def internal_error(_request):
|
||||
raise RuntimeError("secret failure detail")
|
||||
|
||||
app = web.Application(middlewares=[bridge.cors_middleware])
|
||||
for path in PROTECTED_PATHS:
|
||||
app.router.add_route("*", path, protected_handler)
|
||||
app.router.add_get("/ws", bridge.ws_handler)
|
||||
app.router.add_get("/http-error", http_error)
|
||||
app.router.add_get("/internal-error", internal_error)
|
||||
client = TestClient(TestServer(app))
|
||||
await client.start_server()
|
||||
try:
|
||||
await callback(client, side_effect)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
def test_evil_origins_are_rejected_before_sensitive_handlers(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setenv("BRIDGE_PORT", "14168")
|
||||
monkeypatch.delenv("GA_E2E", raising=False)
|
||||
monkeypatch.delenv("VITE_PORT", raising=False)
|
||||
evil_origins = (
|
||||
"null",
|
||||
"https://localhost:5173",
|
||||
"http://localhost:5174",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://127.0.0.1:14169",
|
||||
"http://localhost:5173.evil.example",
|
||||
"http://tauri.localhost.evil.example",
|
||||
"tauri://evil",
|
||||
)
|
||||
|
||||
async def scenario(client: TestClient, side_effect: Path):
|
||||
for origin in evil_origins:
|
||||
for path in PROTECTED_PATHS:
|
||||
for method in ("GET", "POST", "OPTIONS"):
|
||||
response = await client.request(method, path, headers={"Origin": origin})
|
||||
assert response.status == 403, (origin, method, path, await response.text())
|
||||
assert response.headers.get("Access-Control-Allow-Origin") is None
|
||||
assert "protected-secret" not in await response.text()
|
||||
assert not side_effect.exists()
|
||||
|
||||
asyncio.run(_with_client(scenario, tmp_path))
|
||||
|
||||
|
||||
def test_allowed_origins_are_reflected_exactly_without_credentials(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setenv("BRIDGE_PORT", "15168")
|
||||
monkeypatch.setenv("GA_E2E", "1")
|
||||
monkeypatch.setenv("VITE_PORT", "5273")
|
||||
allowed = (
|
||||
"tauri://localhost",
|
||||
"http://tauri.localhost",
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:15168",
|
||||
"http://localhost:15168",
|
||||
"http://[::1]:15168",
|
||||
"http://127.0.0.1:5273",
|
||||
)
|
||||
|
||||
async def scenario(client: TestClient, side_effect: Path):
|
||||
for origin in allowed:
|
||||
response = await client.get("/status", headers={"Origin": origin})
|
||||
assert response.status == 200
|
||||
assert response.headers["Access-Control-Allow-Origin"] == origin
|
||||
assert "origin" in response.headers.get("Vary", "").lower()
|
||||
assert "Access-Control-Allow-Credentials" not in response.headers
|
||||
assert response.headers["Access-Control-Allow-Origin"] != "*"
|
||||
|
||||
preflight = await client.options("/memory/export", headers={"Origin": origin})
|
||||
assert preflight.status == 204
|
||||
assert preflight.headers["Access-Control-Allow-Origin"] == origin
|
||||
assert side_effect.exists()
|
||||
|
||||
asyncio.run(_with_client(scenario, tmp_path))
|
||||
|
||||
|
||||
def test_no_origin_cli_is_allowed_but_cross_site_navigation_is_rejected(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.delenv("GA_E2E", raising=False)
|
||||
|
||||
async def scenario(client: TestClient, side_effect: Path):
|
||||
response = await client.get("/status")
|
||||
assert response.status == 200
|
||||
assert "Access-Control-Allow-Origin" not in response.headers
|
||||
assert await response.text() == "protected-secret"
|
||||
|
||||
side_effect.unlink()
|
||||
rejected = await client.get(
|
||||
"/status", headers={"Sec-Fetch-Site": "cross-site"}
|
||||
)
|
||||
assert rejected.status == 403
|
||||
assert not side_effect.exists()
|
||||
|
||||
asyncio.run(_with_client(scenario, tmp_path))
|
||||
|
||||
|
||||
def test_e2e_origin_requires_explicit_mode_and_a_strict_valid_port(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
async def scenario(client: TestClient, side_effect: Path):
|
||||
origin = "http://127.0.0.1:5273"
|
||||
response = await client.get("/status", headers={"Origin": origin})
|
||||
assert response.status == 403
|
||||
assert not side_effect.exists()
|
||||
|
||||
monkeypatch.setenv("GA_E2E", "1")
|
||||
for invalid in ("", "0", "65536", "52x73", "5273/path"):
|
||||
monkeypatch.setenv("VITE_PORT", invalid)
|
||||
asyncio.run(_with_client(scenario, tmp_path))
|
||||
monkeypatch.setenv("VITE_PORT", "5273")
|
||||
monkeypatch.delenv("GA_E2E")
|
||||
asyncio.run(_with_client(scenario, tmp_path))
|
||||
|
||||
|
||||
def test_allowed_origin_headers_cover_http_errors_and_internal_errors(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
origin = "tauri://localhost"
|
||||
|
||||
async def scenario(client: TestClient, _side_effect: Path):
|
||||
bad = await client.get("/http-error", headers={"Origin": origin})
|
||||
assert bad.status == 400
|
||||
assert bad.headers["Access-Control-Allow-Origin"] == origin
|
||||
failed = await client.get("/internal-error", headers={"Origin": origin})
|
||||
assert failed.status == 500
|
||||
assert failed.headers["Access-Control-Allow-Origin"] == origin
|
||||
assert "secret failure detail" not in await failed.text()
|
||||
|
||||
asyncio.run(_with_client(scenario, tmp_path))
|
||||
|
||||
|
||||
def test_websocket_evil_origin_fails_before_prepare(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.delenv("GA_E2E", raising=False)
|
||||
|
||||
async def scenario(client: TestClient, side_effect: Path):
|
||||
with pytest.raises(WSServerHandshakeError) as raised:
|
||||
await client.ws_connect("/ws", headers={"Origin": "http://evil.example"})
|
||||
assert raised.value.status == 403
|
||||
assert not side_effect.exists()
|
||||
|
||||
asyncio.run(_with_client(scenario, tmp_path))
|
||||
|
||||
|
||||
def test_bridge_source_has_no_wildcard_cors_and_checks_ws_before_prepare():
|
||||
source = Path(bridge.__file__).read_text(encoding="utf-8")
|
||||
assert 'Access-Control-Allow-Origin": "*"' not in source
|
||||
assert "cors_headers" not in source
|
||||
ws_start = source.index("async def ws_handler")
|
||||
ws_end = source.index(
|
||||
"# ---------------------------------------------------------------------------\n# Transport layer",
|
||||
ws_start,
|
||||
)
|
||||
ws_source = source[ws_start:ws_end]
|
||||
assert ws_source.index("_request_origin_error(request)") < ws_source.index("WebSocketResponse")
|
||||
assert ws_source.index("_request_origin_error(request)") < ws_source.index("await ws.prepare(request)")
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,261 @@
|
||||
"""Unit tests for desktop_bridge.py submit_prompt path-prepend logic and add_message.
|
||||
|
||||
Tests the core file-attachment contract: files_meta → agent_prompt path prepend.
|
||||
Run: pytest frontends/tests/test_bridge_submit.py -v
|
||||
"""
|
||||
import json
|
||||
|
||||
|
||||
class TestPathPrepend:
|
||||
"""Test the agent_prompt construction logic from submit_prompt."""
|
||||
|
||||
@staticmethod
|
||||
def _build_agent_prompt(prompt: str, files_meta: list | None) -> str:
|
||||
"""Replicate the path prepend logic from desktop_bridge.py submit_prompt."""
|
||||
agent_prompt = prompt
|
||||
if files_meta:
|
||||
paths = [f["path"] for f in files_meta if f.get("path")]
|
||||
if paths:
|
||||
agent_prompt = " ".join(paths) + "\n" + prompt
|
||||
return agent_prompt
|
||||
|
||||
def test_single_file_prepend(self):
|
||||
prompt = "Analyze this file"
|
||||
files = [{"name": "data.csv", "path": "/tmp/uploads/data.csv", "size": 1024}]
|
||||
result = self._build_agent_prompt(prompt, files)
|
||||
assert result == "/tmp/uploads/data.csv\nAnalyze this file"
|
||||
|
||||
def test_multiple_files_prepend(self):
|
||||
prompt = "Compare these"
|
||||
files = [
|
||||
{"name": "a.py", "path": "/tmp/a.py", "size": 100},
|
||||
{"name": "b.py", "path": "/tmp/b.py", "size": 200},
|
||||
{"name": "c.py", "path": "/tmp/c.py", "size": 300},
|
||||
]
|
||||
result = self._build_agent_prompt(prompt, files)
|
||||
assert result == "/tmp/a.py /tmp/b.py /tmp/c.py\nCompare these"
|
||||
|
||||
def test_no_files_meta_unchanged(self):
|
||||
prompt = "Just a question"
|
||||
assert self._build_agent_prompt(prompt, None) == prompt
|
||||
assert self._build_agent_prompt(prompt, []) == prompt
|
||||
|
||||
def test_files_without_path_skipped(self):
|
||||
prompt = "Check files"
|
||||
files = [
|
||||
{"name": "good.txt", "path": "/tmp/good.txt"},
|
||||
{"name": "bad.txt"}, # no path
|
||||
{"name": "empty.txt", "path": ""}, # empty path
|
||||
]
|
||||
result = self._build_agent_prompt(prompt, files)
|
||||
assert result == "/tmp/good.txt\nCheck files"
|
||||
|
||||
def test_path_with_spaces(self):
|
||||
prompt = "Read it"
|
||||
files = [{"name": "my report.txt", "path": "/tmp/desktop_uploads/my report.txt"}]
|
||||
result = self._build_agent_prompt(prompt, files)
|
||||
assert "/tmp/desktop_uploads/my report.txt" in result
|
||||
|
||||
def test_path_with_cjk(self):
|
||||
prompt = "分析这个"
|
||||
files = [{"name": "报告.csv", "path": "/tmp/desktop_uploads/报告.csv"}]
|
||||
result = self._build_agent_prompt(prompt, files)
|
||||
assert result.startswith("/tmp/desktop_uploads/报告.csv\n")
|
||||
|
||||
def test_empty_prompt_with_files(self):
|
||||
prompt = ""
|
||||
files = [{"name": "f.txt", "path": "/tmp/f.txt"}]
|
||||
result = self._build_agent_prompt(prompt, files)
|
||||
assert result == "/tmp/f.txt\n"
|
||||
|
||||
|
||||
class TestAddMessageExtra:
|
||||
"""Test that add_message stores extra fields correctly."""
|
||||
|
||||
@staticmethod
|
||||
def _simulate_add_message(role: str, content: str, **extra) -> dict:
|
||||
"""Replicate add_message logic from AgentManager."""
|
||||
msg = {
|
||||
"id": 1,
|
||||
"role": role,
|
||||
"content": content,
|
||||
"ts": 1000.0,
|
||||
}
|
||||
msg.update(extra)
|
||||
return msg
|
||||
|
||||
def test_files_stored_in_message(self):
|
||||
files_meta = [{"name": "x.py", "path": "/tmp/x.py", "size": 512}]
|
||||
msg = self._simulate_add_message("user", "hello", files=files_meta)
|
||||
assert msg["files"] == files_meta
|
||||
|
||||
def test_images_stored_in_message(self):
|
||||
image_metas = [{"name": "pic.png", "path": "/tmp/pic.png"}]
|
||||
msg = self._simulate_add_message("user", "see this", images=image_metas)
|
||||
assert msg["images"] == image_metas
|
||||
|
||||
def test_display_stored_separately(self):
|
||||
msg = self._simulate_add_message("user", "full prompt with paths", display="clean display text")
|
||||
assert msg["display"] == "clean display text"
|
||||
assert msg["content"] == "full prompt with paths"
|
||||
|
||||
def test_no_extra_fields_minimal(self):
|
||||
msg = self._simulate_add_message("assistant", "response")
|
||||
assert "files" not in msg
|
||||
assert "images" not in msg
|
||||
assert "display" not in msg
|
||||
|
||||
def test_combined_files_and_images(self):
|
||||
msg = self._simulate_add_message(
|
||||
"user", "both",
|
||||
files=[{"name": "f.txt", "path": "/tmp/f.txt"}],
|
||||
images=[{"name": "i.png", "path": "/tmp/i.png"}],
|
||||
)
|
||||
assert len(msg["files"]) == 1
|
||||
assert len(msg["images"]) == 1
|
||||
|
||||
|
||||
class TestImagePathExtraction:
|
||||
"""Test image_paths extraction from image_metas (used for _patch_chat_for_images)."""
|
||||
|
||||
def test_extracts_paths(self):
|
||||
image_metas = [
|
||||
{"name": "a.png", "path": "/tmp/a.png"},
|
||||
{"name": "b.jpg", "path": "/tmp/b.jpg"},
|
||||
]
|
||||
image_paths = [m["path"] for m in (image_metas or []) if m.get("path")]
|
||||
assert image_paths == ["/tmp/a.png", "/tmp/b.jpg"]
|
||||
|
||||
def test_none_metas(self):
|
||||
image_metas = None
|
||||
image_paths = [m["path"] for m in (image_metas or []) if m.get("path")]
|
||||
assert image_paths == []
|
||||
|
||||
def test_skips_entries_without_path(self):
|
||||
image_metas = [
|
||||
{"name": "a.png", "path": "/tmp/a.png"},
|
||||
{"name": "broken.png"},
|
||||
]
|
||||
image_paths = [m["path"] for m in (image_metas or []) if m.get("path")]
|
||||
assert image_paths == ["/tmp/a.png"]
|
||||
|
||||
|
||||
class TestSessionFiltering:
|
||||
"""Test the tui_ prefix filter for conductor sessions."""
|
||||
|
||||
def test_filters_tui_prefix(self):
|
||||
sessions = [
|
||||
{"id": "abc123", "title": "Chat"},
|
||||
{"id": "tui_worker_1", "title": "Worker"},
|
||||
{"id": "def456", "title": "Another"},
|
||||
{"id": "tui_conductor_main", "title": "Main"},
|
||||
]
|
||||
filtered = [s for s in sessions if not s["id"].startswith("tui_")]
|
||||
assert len(filtered) == 2
|
||||
assert all(not s["id"].startswith("tui_") for s in filtered)
|
||||
|
||||
def test_no_tui_sessions_unchanged(self):
|
||||
sessions = [{"id": "a"}, {"id": "b"}]
|
||||
filtered = [s for s in sessions if not s["id"].startswith("tui_")]
|
||||
assert len(filtered) == 2
|
||||
|
||||
def test_all_tui_sessions_empty_result(self):
|
||||
sessions = [{"id": "tui_x"}, {"id": "tui_y"}]
|
||||
filtered = [s for s in sessions if not s["id"].startswith("tui_")]
|
||||
assert len(filtered) == 0
|
||||
|
||||
|
||||
class TestRunAgentTurnScope:
|
||||
"""Guard tests: run_agent_turn must NOT reference variables from submit_prompt's scope.
|
||||
|
||||
These tests parse the actual bridge source to catch NameError regressions
|
||||
(e.g. referencing 'sid' inside run_agent_turn which runs in a different thread).
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _get_bridge_source() -> str:
|
||||
from pathlib import Path
|
||||
candidates = [
|
||||
Path(__file__).parent.parent / "desktop_bridge.py",
|
||||
Path(__file__).parent.parent.parent / "frontends" / "desktop_bridge.py",
|
||||
]
|
||||
for p in candidates:
|
||||
if p.exists():
|
||||
return p.read_text(encoding="utf-8")
|
||||
raise FileNotFoundError("desktop_bridge.py not found")
|
||||
|
||||
@staticmethod
|
||||
def _extract_method_body(source: str, method_name: str) -> str:
|
||||
"""Extract a method with Python's parser so multiline signatures remain supported."""
|
||||
import ast
|
||||
tree = ast.parse(source)
|
||||
method = next(
|
||||
(
|
||||
node
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and node.name == method_name
|
||||
),
|
||||
None,
|
||||
)
|
||||
return ast.get_source_segment(source, method) if method is not None else ""
|
||||
|
||||
def test_run_agent_turn_does_not_reference_bare_sid(self):
|
||||
"""run_agent_turn must use sess.id, never bare 'sid' (which is out of scope)."""
|
||||
import re
|
||||
source = self._get_bridge_source()
|
||||
body = self._extract_method_body(source, "run_agent_turn")
|
||||
assert body, "Could not extract run_agent_turn body"
|
||||
# Find bare 'sid' references that are NOT part of 'sess.id' or string literals
|
||||
bare_sid_refs = re.findall(r'(?<!\.)(?<!\w)sid(?!\w)(?!.*#.*sid)', body)
|
||||
# Filter out string contents (inside quotes)
|
||||
real_refs = []
|
||||
for match in re.finditer(r'(?<!\.)(?<!\w)sid(?!\w)', body):
|
||||
pos = match.start()
|
||||
# Check we're not inside a string
|
||||
before = body[:pos]
|
||||
single_q = before.count("'") % 2
|
||||
double_q = before.count('"') % 2
|
||||
if not single_q and not double_q:
|
||||
real_refs.append(match.group())
|
||||
assert len(real_refs) == 0, (
|
||||
f"run_agent_turn references bare 'sid' {len(real_refs)} time(s). "
|
||||
f"Use 'sess.id' instead — 'sid' is only defined in submit_prompt's scope."
|
||||
)
|
||||
|
||||
def test_patch_chat_for_images_exists(self):
|
||||
"""_patch_chat_for_images must exist in bridge — it's the image injection path."""
|
||||
source = self._get_bridge_source()
|
||||
assert "_patch_chat_for_images" in source, (
|
||||
"_patch_chat_for_images method missing from desktop_bridge.py — "
|
||||
"image uploads will silently fail (agent won't see images)"
|
||||
)
|
||||
|
||||
def test_submit_prompt_separates_agent_prompt_from_stored_message(self):
|
||||
"""submit_prompt must store clean prompt in message but pass paths to agent."""
|
||||
source = self._get_bridge_source()
|
||||
body = self._extract_method_body(source, "submit_prompt")
|
||||
assert body, "Could not extract submit_prompt body"
|
||||
# Must have agent_prompt as a separate variable
|
||||
assert "agent_prompt" in body, (
|
||||
"submit_prompt must use 'agent_prompt' (with paths) separate from 'prompt' "
|
||||
"(clean, stored in message). Otherwise UI shows raw paths to user."
|
||||
)
|
||||
# add_message must use the clean prompt, not agent_prompt
|
||||
import re
|
||||
add_msg_call = re.search(r'add_message\(.*?,\s*"user",\s*(\w+)', body)
|
||||
assert add_msg_call, "add_message call not found in submit_prompt"
|
||||
arg = add_msg_call.group(1)
|
||||
assert arg == "prompt", (
|
||||
f"add_message stores '{arg}' but should store 'prompt' (clean text). "
|
||||
f"agent_prompt (with file paths) goes only to run_agent_turn."
|
||||
)
|
||||
|
||||
def test_image_paths_passed_to_run_agent_turn(self):
|
||||
"""submit_prompt must extract image_paths and pass to run_agent_turn thread."""
|
||||
source = self._get_bridge_source()
|
||||
body = self._extract_method_body(source, "submit_prompt")
|
||||
assert "image_paths" in body, (
|
||||
"submit_prompt must extract image_paths from image_metas and pass to "
|
||||
"run_agent_turn. Without this, _patch_chat_for_images receives None."
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Unit tests for desktop_bridge.py upload/path-safety logic.
|
||||
|
||||
Follows the existing bridge testing strategy: replicate pure upload/path logic
|
||||
without importing the whole desktop_bridge module (which starts services).
|
||||
Run: pytest frontends/tests/test_bridge_uploads.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# Mirrors desktop_bridge._safe_session_dir
|
||||
def _safe_session_dir(sid: str | None) -> str:
|
||||
s = re.sub(r"[^A-Za-z0-9_-]", "", str(sid or ""))
|
||||
return s or "_misc"
|
||||
|
||||
|
||||
# Mirrors desktop_bridge._session_upload_dir
|
||||
def _session_upload_dir(upload_root: Path, sid: str) -> Path:
|
||||
return upload_root / _safe_session_dir(sid)
|
||||
|
||||
|
||||
# Mirrors the upload_handler naming/path rules.
|
||||
def _build_upload_path(upload_root: Path, sid: str, name: str, token: str) -> Path:
|
||||
safe_name = (name or "file").strip().replace("/", "_").replace("\\", "_") or "file"
|
||||
return _session_upload_dir(upload_root, sid) / f"{token}__{safe_name}"
|
||||
|
||||
|
||||
# Mirrors upload_handler decode logic.
|
||||
def _decode_upload_data(data_url: str) -> bytes:
|
||||
if "," in data_url:
|
||||
b64 = data_url.split(",", 1)[1]
|
||||
else:
|
||||
b64 = data_url
|
||||
return base64.b64decode(b64)
|
||||
|
||||
|
||||
# Mirrors upload_delete_handler path whitelist.
|
||||
def _delete_allowed(upload_root: Path, raw_path: str) -> bool:
|
||||
target = Path(raw_path).resolve()
|
||||
root = upload_root.resolve()
|
||||
return root in target.parents
|
||||
|
||||
|
||||
# Mirrors upload_raw_handler path whitelist.
|
||||
def _raw_allowed(upload_root: Path, raw_path: str) -> bool:
|
||||
target = Path(raw_path).resolve()
|
||||
try:
|
||||
target.relative_to(upload_root.resolve())
|
||||
return True
|
||||
except (ValueError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
# Mirrors upload_raw_handler original filename recovery.
|
||||
def _original_upload_name(path: Path) -> str:
|
||||
return path.name.split("__", 1)[-1]
|
||||
|
||||
|
||||
class TestSafeSessionDir:
|
||||
def test_keeps_safe_ascii_chars(self):
|
||||
assert _safe_session_dir("abc-DEF_123") == "abc-DEF_123"
|
||||
|
||||
def test_strips_path_traversal_and_spaces(self):
|
||||
assert _safe_session_dir(" ../../team notes ") == "teamnotes"
|
||||
|
||||
def test_empty_or_none_falls_back_to_misc(self):
|
||||
assert _safe_session_dir("") == "_misc"
|
||||
assert _safe_session_dir(None) == "_misc"
|
||||
|
||||
def test_non_ascii_only_sid_falls_back_to_misc(self):
|
||||
assert _safe_session_dir("报告/会议") == "_misc"
|
||||
|
||||
|
||||
class TestSessionUploadDir:
|
||||
def test_scopes_uploads_under_sanitized_sid(self, tmp_path: Path):
|
||||
root = tmp_path / "desktop_uploads"
|
||||
actual = _session_upload_dir(root, "../../tui_worker")
|
||||
assert actual == root / "tui_worker"
|
||||
|
||||
def test_files_bucket_stays_isolated(self, tmp_path: Path):
|
||||
root = tmp_path / "desktop_uploads"
|
||||
actual = _session_upload_dir(root, "_files")
|
||||
assert actual == root / "_files"
|
||||
|
||||
|
||||
class TestUploadPathConstruction:
|
||||
def test_replaces_slashes_in_uploaded_name(self, tmp_path: Path):
|
||||
root = tmp_path / "desktop_uploads"
|
||||
path = _build_upload_path(root, "session-1", "dir/sub\\name.txt", "abc123def456")
|
||||
assert path == root / "session-1" / "abc123def456__dir_sub_name.txt"
|
||||
|
||||
def test_uses_file_when_name_empty(self, tmp_path: Path):
|
||||
root = tmp_path / "desktop_uploads"
|
||||
path = _build_upload_path(root, "session-1", " ", "abc123def456")
|
||||
assert path.name == "abc123def456__file"
|
||||
|
||||
|
||||
class TestUploadDecode:
|
||||
def test_decodes_data_url_payload(self):
|
||||
payload = "hello,world".encode("utf-8")
|
||||
data_url = "data:text/plain;base64," + base64.b64encode(payload).decode("ascii")
|
||||
assert _decode_upload_data(data_url) == payload
|
||||
|
||||
def test_decodes_raw_base64_payload(self):
|
||||
payload = b"abc123"
|
||||
raw = base64.b64encode(payload).decode("ascii")
|
||||
assert _decode_upload_data(raw) == payload
|
||||
|
||||
|
||||
class TestUploadPathSafety:
|
||||
def test_delete_allows_file_inside_upload_root(self, tmp_path: Path):
|
||||
root = tmp_path / "desktop_uploads"
|
||||
target = root / "session-1" / "abc__note.txt"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("ok", encoding="utf-8")
|
||||
assert _delete_allowed(root, str(target)) is True
|
||||
|
||||
def test_delete_rejects_file_outside_upload_root(self, tmp_path: Path):
|
||||
root = tmp_path / "desktop_uploads"
|
||||
outside = tmp_path / "outside.txt"
|
||||
outside.write_text("nope", encoding="utf-8")
|
||||
assert _delete_allowed(root, str(outside)) is False
|
||||
|
||||
def test_raw_allows_file_inside_upload_root(self, tmp_path: Path):
|
||||
root = tmp_path / "desktop_uploads"
|
||||
target = root / "session-1" / "abc__report.csv"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("x,y", encoding="utf-8")
|
||||
assert _raw_allowed(root, str(target)) is True
|
||||
|
||||
def test_raw_rejects_path_traversal_outside_upload_root(self, tmp_path: Path):
|
||||
root = tmp_path / "desktop_uploads"
|
||||
outside = tmp_path / "secret.txt"
|
||||
outside.write_text("top secret", encoding="utf-8")
|
||||
assert _raw_allowed(root, str(outside)) is False
|
||||
|
||||
|
||||
class TestOriginalUploadName:
|
||||
def test_strips_uuid_prefix_from_uploaded_file(self):
|
||||
path = Path("/tmp/desktop_uploads/session-1/abc123def456__my report.txt")
|
||||
assert _original_upload_name(path) == "my report.txt"
|
||||
|
||||
def test_preserves_cjk_filename(self):
|
||||
path = Path("/tmp/desktop_uploads/session-1/abc123def456__报告.csv")
|
||||
assert _original_upload_name(path) == "报告.csv"
|
||||
|
||||
def test_filename_without_prefix_returns_name_as_is(self):
|
||||
path = Path("/tmp/desktop_uploads/session-1/plain.txt")
|
||||
assert _original_upload_name(path) == "plain.txt"
|
||||
@@ -0,0 +1,420 @@
|
||||
"""Unit tests for desktop_bridge.py utility functions.
|
||||
|
||||
Tests pure functions that don't require agent/session infrastructure.
|
||||
Run: pytest frontends/tests/test_bridge_utils.py -v
|
||||
"""
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
# Add project root so we can import bridge helpers
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(ROOT / "frontends"))
|
||||
from data_backup import materialize_import_source, merge_data_files
|
||||
|
||||
# Import the functions under test (module-level helpers)
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("desktop_bridge", ROOT / "frontends" / "desktop_bridge.py")
|
||||
|
||||
# We can't import the whole module (it starts aiohttp etc.) so we extract functions manually
|
||||
_bridge_source = (ROOT / "frontends" / "desktop_bridge.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _load_named_helpers(names, namespace):
|
||||
tree = ast.parse(_bridge_source)
|
||||
nodes = [
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in names
|
||||
]
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), "desktop_bridge.py", "exec"), namespace)
|
||||
return namespace
|
||||
|
||||
|
||||
class TestGaRootBoundary:
|
||||
def test_external_core_override_needs_only_agentmain(self, tmp_path, monkeypatch):
|
||||
external = tmp_path / "external core"
|
||||
external.mkdir()
|
||||
(external / "agentmain.py").write_text("# core\n", encoding="utf-8")
|
||||
monkeypatch.setenv("GA_ROOT", str(external))
|
||||
monkeypatch.setattr(sys, "argv", ["desktop_bridge.py"])
|
||||
|
||||
helpers = _load_named_helpers(
|
||||
{"_ga_root_override"},
|
||||
{"os": os, "sys": sys, "Path": Path, "Optional": __import__("typing").Optional},
|
||||
)
|
||||
|
||||
assert helpers["_ga_root_override"]() == external.resolve()
|
||||
|
||||
def test_invalid_override_falls_back_instead_of_becoming_app_dir(self, tmp_path, monkeypatch):
|
||||
missing = tmp_path / "deleted-core"
|
||||
monkeypatch.setenv("GA_ROOT", str(missing))
|
||||
monkeypatch.setattr(sys, "argv", ["desktop_bridge.py"])
|
||||
helpers = _load_named_helpers(
|
||||
{"_ga_root_override"},
|
||||
{"os": os, "sys": sys, "Path": Path, "Optional": __import__("typing").Optional},
|
||||
)
|
||||
|
||||
assert helpers["_ga_root_override"]() is None
|
||||
|
||||
|
||||
class TestMemoryImport:
|
||||
def test_source_memory_wins_while_current_responses_remain_add_only(self, tmp_path):
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
(source / "memory").mkdir(parents=True)
|
||||
(source / "memory" / "same.md").write_text("new", encoding="utf-8")
|
||||
(source / "memory" / "added.md").write_text("added", encoding="utf-8")
|
||||
(source / "temp" / "model_responses").mkdir(parents=True)
|
||||
(source / "temp" / "model_responses" / "same.json").write_text("new", encoding="utf-8")
|
||||
(source / "temp" / "model_responses" / "added.json").write_text("added", encoding="utf-8")
|
||||
(target / "memory").mkdir(parents=True)
|
||||
(target / "memory" / "same.md").write_text("old", encoding="utf-8")
|
||||
(target / "temp" / "model_responses").mkdir(parents=True)
|
||||
(target / "temp" / "model_responses" / "same.json").write_text("old", encoding="utf-8")
|
||||
|
||||
result = merge_data_files(str(source), str(target))
|
||||
|
||||
assert result["memoryCopied"] == 2
|
||||
assert result["memorySkipped"] == 0
|
||||
assert result["responsesCopied"] == 1
|
||||
assert result["responsesSkipped"] == 1
|
||||
assert (target / "memory" / "same.md").read_text(encoding="utf-8") == "new"
|
||||
assert (target / "memory" / "added.md").read_text(encoding="utf-8") == "added"
|
||||
assert (target / "temp" / "model_responses" / "same.json").read_text(encoding="utf-8") == "old"
|
||||
assert (Path(result["backupDir"]) / "memory" / "same.md").read_text(encoding="utf-8") == "old"
|
||||
|
||||
def test_bridge_adopts_only_sessions_committed_by_transaction(self, tmp_path):
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
(source / "memory").mkdir(parents=True)
|
||||
(source / "memory" / "imported.md").write_text("new", encoding="utf-8")
|
||||
source_sessions = source / "temp" / "desktop_sessions"
|
||||
source_sessions.mkdir(parents=True)
|
||||
source_sessions.joinpath("sess-imported.json").write_text(
|
||||
json.dumps({"id": "sess-imported", "title": "Imported", "messages": []}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
target.mkdir()
|
||||
|
||||
class Lock:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
class Manager:
|
||||
ga_root = str(target)
|
||||
lock = Lock()
|
||||
sessions = {}
|
||||
|
||||
@staticmethod
|
||||
def begin_maintenance(kind, running_extras_fn):
|
||||
assert kind == "import"
|
||||
assert running_extras_fn() == []
|
||||
return "test-token"
|
||||
|
||||
@staticmethod
|
||||
def end_maintenance(token):
|
||||
assert token == "test-token"
|
||||
|
||||
@staticmethod
|
||||
def _session_from_item(item):
|
||||
return SimpleNamespace(id=item["id"], title=item.get("title", ""))
|
||||
|
||||
helpers = _load_named_helpers(
|
||||
{"_import_data_source"},
|
||||
{
|
||||
"manager": Manager(),
|
||||
"services": SimpleNamespace(running_managed_ids=lambda: []),
|
||||
"materialize_import_source": materialize_import_source,
|
||||
"merge_data_files": merge_data_files,
|
||||
},
|
||||
)
|
||||
|
||||
result = helpers["_import_data_source"](str(source))
|
||||
|
||||
manager = helpers["manager"]
|
||||
assert result["sessionsAdded"] == 1
|
||||
assert "_preparedSessions" not in result
|
||||
assert manager.sessions["sess-imported"].title == "Imported"
|
||||
persisted = target / "temp" / "desktop_sessions" / "sess-imported.json"
|
||||
assert json.loads(persisted.read_text(encoding="utf-8"))["id"] == "sess-imported"
|
||||
|
||||
|
||||
def _load_empty_turn_helpers():
|
||||
tree = ast.parse(_bridge_source)
|
||||
wanted = {"_EMPTY_TURN_MICROCOPY", "_get_ui_lang", "empty_turn_fallback"}
|
||||
nodes = []
|
||||
for node in tree.body:
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in wanted:
|
||||
nodes.append(node)
|
||||
elif isinstance(node, ast.Assign):
|
||||
names = {target.id for target in node.targets if isinstance(target, ast.Name)}
|
||||
if names & wanted:
|
||||
nodes.append(node)
|
||||
namespace = {"Path": Path, "json": json}
|
||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), "desktop_bridge.py", "exec"), namespace)
|
||||
return namespace
|
||||
|
||||
|
||||
class TestEmptyTurnFallback:
|
||||
def test_uses_english_microcopy(self, tmp_path, monkeypatch):
|
||||
(tmp_path / ".ga_desktop_settings.json").write_text(
|
||||
'{"ui":{"lang":"en"},"lang":"zh"}', encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
helpers = _load_empty_turn_helpers()
|
||||
|
||||
assert helpers["empty_turn_fallback"]() == (
|
||||
'⚠️ This turn ended without a visible response. You can send "continue" to retry.'
|
||||
)
|
||||
|
||||
def test_supports_legacy_top_level_language(self, tmp_path, monkeypatch):
|
||||
(tmp_path / ".ga_desktop_settings.json").write_text('{"lang":"en"}', encoding="utf-8")
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
helpers = _load_empty_turn_helpers()
|
||||
|
||||
assert helpers["empty_turn_fallback"]() == (
|
||||
'⚠️ This turn ended without a visible response. You can send "continue" to retry.'
|
||||
)
|
||||
|
||||
def test_defaults_to_chinese_for_missing_or_invalid_settings(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
helpers = _load_empty_turn_helpers()
|
||||
expected = '⚠️ 这一轮结束了,但没有产出可见回复。你可以发送"继续"重试。'
|
||||
assert helpers["empty_turn_fallback"]() == expected
|
||||
|
||||
(tmp_path / ".ga_desktop_settings.json").write_text("not-json", encoding="utf-8")
|
||||
assert helpers["empty_turn_fallback"]() == expected
|
||||
|
||||
def test_defaults_to_chinese_for_non_string_language(self, tmp_path, monkeypatch):
|
||||
(tmp_path / ".ga_desktop_settings.json").write_text(
|
||||
'{"ui":{"lang":[]},"lang":[]}', encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
helpers = _load_empty_turn_helpers()
|
||||
|
||||
assert helpers["empty_turn_fallback"]() == (
|
||||
'⚠️ 这一轮结束了,但没有产出可见回复。你可以发送"继续"重试。'
|
||||
)
|
||||
|
||||
|
||||
# === strip_final_info_marker ===
|
||||
|
||||
_FINAL_INFO_RE = re.compile(r'\n*`{5}\n*\[Info\] Final response to user\.\n*`{5}\s*$')
|
||||
|
||||
def strip_final_info_marker(text):
|
||||
return _FINAL_INFO_RE.sub('', str(text or ''))
|
||||
|
||||
|
||||
class TestStripFinalInfoMarker:
|
||||
def test_removes_marker_at_end(self):
|
||||
text = "Hello world\n`````\n[Info] Final response to user.\n`````"
|
||||
assert strip_final_info_marker(text) == "Hello world"
|
||||
|
||||
def test_no_marker_unchanged(self):
|
||||
text = "Just normal text"
|
||||
assert strip_final_info_marker(text) == "Just normal text"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert strip_final_info_marker("") == ""
|
||||
|
||||
def test_none_becomes_empty(self):
|
||||
# str(None or '') → '' since `or` short-circuits
|
||||
assert strip_final_info_marker(None) == ""
|
||||
|
||||
def test_marker_only_in_middle_not_removed(self):
|
||||
text = "Before\n`````\n[Info] Final response to user.\n`````\nAfter"
|
||||
assert strip_final_info_marker(text) == text
|
||||
|
||||
|
||||
# === normalize_final_turn_segs ===
|
||||
|
||||
def normalize_final_turn_segs(full, outputs):
|
||||
if not outputs or not isinstance(outputs, (list, tuple)):
|
||||
return None
|
||||
segs = [strip_final_info_marker(s) for s in outputs]
|
||||
full_text = strip_final_info_marker(full)
|
||||
if not segs:
|
||||
return None
|
||||
joined = "".join(segs)
|
||||
if full_text.strip() == joined.strip():
|
||||
return segs
|
||||
if joined and full_text.startswith(joined):
|
||||
suffix = full_text[len(joined):]
|
||||
if suffix.strip():
|
||||
segs[-1] = segs[-1] + suffix
|
||||
return segs
|
||||
return None
|
||||
|
||||
|
||||
class TestNormalizeFinalTurnSegs:
|
||||
def test_exact_match(self):
|
||||
segs = normalize_final_turn_segs("AB", ["A", "B"])
|
||||
assert segs == ["A", "B"]
|
||||
|
||||
def test_suffix_appended_to_last_seg(self):
|
||||
segs = normalize_final_turn_segs("ABC_extra", ["A", "BC"])
|
||||
assert segs is not None
|
||||
assert segs[-1] == "BC_extra"
|
||||
|
||||
def test_no_match_returns_none(self):
|
||||
segs = normalize_final_turn_segs("XYZ", ["A", "B"])
|
||||
assert segs is None
|
||||
|
||||
def test_none_outputs_returns_none(self):
|
||||
assert normalize_final_turn_segs("text", None) is None
|
||||
|
||||
def test_empty_outputs_returns_none(self):
|
||||
assert normalize_final_turn_segs("text", []) is None
|
||||
|
||||
def test_string_outputs_returns_none(self):
|
||||
assert normalize_final_turn_segs("text", "not a list") is None
|
||||
|
||||
def test_whitespace_match(self):
|
||||
segs = normalize_final_turn_segs("A B ", ["A B "])
|
||||
assert segs == ["A B "]
|
||||
|
||||
|
||||
# === _extract_first_timestamp ===
|
||||
|
||||
def _extract_first_timestamp(content):
|
||||
m = re.search(r'^=== Prompt === (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})', content, re.MULTILINE)
|
||||
if m:
|
||||
try:
|
||||
from datetime import datetime
|
||||
return datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S").timestamp()
|
||||
except Exception:
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
|
||||
class TestExtractFirstTimestamp:
|
||||
def test_extracts_valid_timestamp(self):
|
||||
content = "=== Prompt === 2024-06-15 14:30:00\nSome prompt text"
|
||||
ts = _extract_first_timestamp(content)
|
||||
assert ts > 0
|
||||
from datetime import datetime
|
||||
expected = datetime(2024, 6, 15, 14, 30, 0).timestamp()
|
||||
assert ts == expected
|
||||
|
||||
def test_no_match_returns_zero(self):
|
||||
assert _extract_first_timestamp("No timestamp here") == 0.0
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _extract_first_timestamp("") == 0.0
|
||||
|
||||
def test_multiline_finds_first(self):
|
||||
content = "Some header\n=== Prompt === 2024-01-01 00:00:00\n=== Response === 2024-01-01 00:01:00"
|
||||
ts = _extract_first_timestamp(content)
|
||||
from datetime import datetime
|
||||
assert ts == datetime(2024, 1, 1, 0, 0, 0).timestamp()
|
||||
|
||||
|
||||
# === _next_native_var ===
|
||||
|
||||
def _next_native_var(text, protocol):
|
||||
proto = str(protocol or "").strip().lower()
|
||||
if proto == "claude":
|
||||
prefix = "native_claude_config"
|
||||
elif proto in ("oai", "openai"):
|
||||
prefix = "native_oai_config"
|
||||
else:
|
||||
raise ValueError("protocol is required: choose 'oai' or 'claude'")
|
||||
nums = [0]
|
||||
if re.search(rf"^{prefix}\s*=", text, re.M):
|
||||
nums.append(0)
|
||||
nums.extend(int(m.group(1)) for m in re.finditer(rf"^{prefix}(\d+)\s*=", text, re.M))
|
||||
n = max(nums) + 1
|
||||
return prefix if n == 1 and not re.search(rf"^{prefix}\s*=", text, re.M) else f"{prefix}{n}"
|
||||
|
||||
|
||||
class TestNextNativeVar:
|
||||
def test_first_oai_config(self):
|
||||
assert _next_native_var("", "oai") == "native_oai_config"
|
||||
|
||||
def test_first_claude_config(self):
|
||||
assert _next_native_var("", "claude") == "native_claude_config"
|
||||
|
||||
def test_increments_when_one_exists(self):
|
||||
text = "native_oai_config = {'key': 'xxx'}"
|
||||
result = _next_native_var(text, "oai")
|
||||
# When base var exists: nums=[0,0], max=0, n=1, but base already exists → prefix1
|
||||
assert result == "native_oai_config1"
|
||||
|
||||
def test_increments_past_existing_numbered(self):
|
||||
text = "native_claude_config = {}\nnative_claude_config2 = {}\nnative_claude_config3 = {}"
|
||||
result = _next_native_var(text, "claude")
|
||||
assert result == "native_claude_config4"
|
||||
|
||||
def test_raises_on_invalid_protocol(self):
|
||||
import pytest
|
||||
with pytest.raises(ValueError, match="protocol is required"):
|
||||
_next_native_var("", "gemini")
|
||||
|
||||
def test_openai_alias(self):
|
||||
assert _next_native_var("", "openai") == "native_oai_config"
|
||||
|
||||
|
||||
# === _format_py_dict ===
|
||||
|
||||
def _format_py_dict(d):
|
||||
lines = [f" '{k}': {json.dumps(v, ensure_ascii=False)}," if isinstance(v, str) else f" '{k}': {v}," for k, v in d.items()]
|
||||
return "{\n" + "\n".join(lines) + "\n}"
|
||||
|
||||
|
||||
class TestFormatPyDict:
|
||||
def test_simple_dict(self):
|
||||
result = _format_py_dict({"key": "sk-xxx", "model": "gpt-4"})
|
||||
assert "'key': \"sk-xxx\"" in result
|
||||
assert "'model': \"gpt-4\"" in result
|
||||
assert result.startswith("{")
|
||||
assert result.endswith("}")
|
||||
|
||||
def test_non_string_values(self):
|
||||
result = _format_py_dict({"timeout": 30, "stream": True})
|
||||
assert "'timeout': 30," in result
|
||||
assert "'stream': True," in result
|
||||
|
||||
def test_empty_dict(self):
|
||||
result = _format_py_dict({})
|
||||
assert result == "{\n\n}"
|
||||
|
||||
def test_chinese_values_preserved(self):
|
||||
result = _format_py_dict({"name": "模型一"})
|
||||
assert "模型一" in result
|
||||
|
||||
|
||||
# === _load_plan_baseline ===
|
||||
|
||||
def _load_plan_baseline(item, msgs):
|
||||
base = int(item.get("plan_scan_baseline", 0) or 0)
|
||||
if base >= len(msgs):
|
||||
return 0
|
||||
return max(0, base)
|
||||
|
||||
|
||||
class TestLoadPlanBaseline:
|
||||
def test_valid_baseline(self):
|
||||
assert _load_plan_baseline({"plan_scan_baseline": 5}, list(range(10))) == 5
|
||||
|
||||
def test_baseline_exceeds_messages_returns_zero(self):
|
||||
assert _load_plan_baseline({"plan_scan_baseline": 20}, list(range(5))) == 0
|
||||
|
||||
def test_missing_key_returns_zero(self):
|
||||
assert _load_plan_baseline({}, list(range(10))) == 0
|
||||
|
||||
def test_none_value_returns_zero(self):
|
||||
assert _load_plan_baseline({"plan_scan_baseline": None}, list(range(10))) == 0
|
||||
|
||||
def test_negative_clamped_to_zero(self):
|
||||
assert _load_plan_baseline({"plan_scan_baseline": -5}, list(range(10))) == 0
|
||||
@@ -0,0 +1,411 @@
|
||||
"""Runtime model-routing tests for the isolated Conductor process."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import argparse
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
CONDUCTOR_PATH = ROOT / "frontends" / "conductor.py"
|
||||
|
||||
|
||||
class StubGenericAgent:
|
||||
pass
|
||||
|
||||
|
||||
agentmain_stub = types.ModuleType("agentmain")
|
||||
agentmain_stub.GenericAgent = StubGenericAgent
|
||||
previous_agentmain = sys.modules.get("agentmain")
|
||||
sys.modules["agentmain"] = agentmain_stub
|
||||
|
||||
spec = importlib.util.spec_from_file_location("conductor_model_under_test", CONDUCTOR_PATH)
|
||||
conductor = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
_argv = sys.argv
|
||||
try:
|
||||
sys.argv = [str(CONDUCTOR_PATH), "--no-browser"]
|
||||
spec.loader.exec_module(conductor)
|
||||
finally:
|
||||
sys.argv = _argv
|
||||
if previous_agentmain is None:
|
||||
del sys.modules["agentmain"]
|
||||
else:
|
||||
sys.modules["agentmain"] = previous_agentmain
|
||||
|
||||
|
||||
class Backend:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.model = name
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, name: str | None):
|
||||
if name is not None:
|
||||
self.backend = Backend(name)
|
||||
|
||||
|
||||
class FakeAgent:
|
||||
def __init__(self, names: list[str | None], failing: set[int] | None = None):
|
||||
self.llmclients = [Client(name) for name in names]
|
||||
self.llm_no = 0
|
||||
self.llmclient = self.llmclients[0]
|
||||
self.next_llm_calls: list[int] = []
|
||||
self.reloads = 0
|
||||
self.failing = failing or set()
|
||||
|
||||
def load_llm_sessions(self):
|
||||
self.reloads += 1
|
||||
|
||||
def next_llm(self, no: int):
|
||||
# Deliberately mirrors GenericAgent's modulo behavior. The Conductor
|
||||
# resolver must validate before calling this method.
|
||||
self.next_llm_calls.append(no)
|
||||
if no in self.failing:
|
||||
raise RuntimeError("broken client")
|
||||
self.llm_no = no % len(self.llmclients)
|
||||
self.llmclient = self.llmclients[self.llm_no]
|
||||
|
||||
def get_llm_name(self, client=None, model=False):
|
||||
client = client or self.llmclient
|
||||
return client.backend.model if model else client.backend.name
|
||||
|
||||
|
||||
def test_out_of_range_config_falls_back_without_modulo(monkeypatch):
|
||||
agent = FakeAgent(["zero", "one", "two", "three"])
|
||||
monkeypatch.setattr(
|
||||
conductor,
|
||||
"_settings_doc",
|
||||
lambda: {"conductor": {"llmNo": 99}, "ui": {"llmNo": 1}},
|
||||
)
|
||||
|
||||
state = conductor._apply_desktop_model(agent)
|
||||
|
||||
assert agent.next_llm_calls == [1]
|
||||
assert state["effective"] == 1
|
||||
assert state["fallbackReason"] == "invalid_configured"
|
||||
|
||||
|
||||
def test_conductor_default_port_override_is_e2e_scoped(monkeypatch):
|
||||
monkeypatch.delenv(conductor.E2E_REPORT_DIR_ENV, raising=False)
|
||||
monkeypatch.setenv(conductor.E2E_CONDUCTOR_PORT_ENV, "29890")
|
||||
assert conductor._default_conductor_port() == 8900
|
||||
|
||||
monkeypatch.setenv(conductor.E2E_REPORT_DIR_ENV, "/tmp/package-evidence")
|
||||
assert conductor._default_conductor_port() == 29890
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["1", "8900", "65535"])
|
||||
def test_conductor_port_parser_accepts_full_valid_range(value):
|
||||
assert conductor._parse_conductor_port(value) == int(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["", "0", "65536", "2.5", "-1", "1234"])
|
||||
def test_conductor_port_parser_rejects_invalid_values(value):
|
||||
with pytest.raises(argparse.ArgumentTypeError, match="between 1 and 65535"):
|
||||
conductor._parse_conductor_port(value)
|
||||
|
||||
|
||||
def test_unusable_configured_client_falls_back_to_ui_default(monkeypatch):
|
||||
agent = FakeAgent(["zero", "one", None, "three"])
|
||||
monkeypatch.setattr(
|
||||
conductor,
|
||||
"_settings_doc",
|
||||
lambda: {"conductor": {"llmNo": 2}, "ui": {"llmNo": 1}},
|
||||
)
|
||||
|
||||
state = conductor._apply_desktop_model(agent)
|
||||
|
||||
assert agent.next_llm_calls == [1]
|
||||
assert state["effective"] == 1
|
||||
assert state["fallbackReason"] == "configured_unavailable"
|
||||
|
||||
|
||||
def test_configured_activation_failure_falls_back_to_ui_default(monkeypatch):
|
||||
agent = FakeAgent(["zero", "one", "two"], failing={2})
|
||||
monkeypatch.setattr(
|
||||
conductor,
|
||||
"_settings_doc",
|
||||
lambda: {"conductor": {"llmNo": 2}, "ui": {"llmNo": 1}},
|
||||
)
|
||||
|
||||
state = conductor._apply_desktop_model(agent)
|
||||
|
||||
assert agent.next_llm_calls == [2, 1]
|
||||
assert state["effective"] == 1
|
||||
assert state["fallbackReason"] == "configured_unavailable"
|
||||
|
||||
|
||||
def test_missing_config_uses_first_usable_when_ui_is_invalid(monkeypatch):
|
||||
agent = FakeAgent([None, None, "two", "three"])
|
||||
monkeypatch.setattr(conductor, "_settings_doc", lambda: {"ui": {"llmNo": 99}})
|
||||
|
||||
state = conductor._apply_desktop_model(agent)
|
||||
|
||||
assert agent.next_llm_calls == [2]
|
||||
assert state["effective"] == 2
|
||||
assert state["fallbackReason"] == "first_available"
|
||||
|
||||
|
||||
def test_explicit_numeric_worker_model_rejects_out_of_range():
|
||||
agent = FakeAgent(["zero", "one"])
|
||||
|
||||
with pytest.raises(ValueError, match="out of range"):
|
||||
conductor._select_llm(agent, 99)
|
||||
|
||||
assert agent.next_llm_calls == []
|
||||
|
||||
|
||||
def test_runtime_model_snapshot_is_broadcast_with_running_state(monkeypatch):
|
||||
instance = conductor.Conductor()
|
||||
payloads: list[dict] = []
|
||||
monkeypatch.setattr(conductor, "schedule_broadcast", payloads.append)
|
||||
state = {
|
||||
"configured": 2,
|
||||
"effective": 1,
|
||||
"fallbackReason": "configured_unavailable",
|
||||
"current": "model-one",
|
||||
}
|
||||
|
||||
instance._publish_model_state(state, running=True)
|
||||
|
||||
assert instance.model_snapshot() == {**state, "running": True}
|
||||
assert payloads == [{"type": "model", "model": {**state, "running": True}}]
|
||||
|
||||
|
||||
def test_long_lived_conductor_refreshes_model_between_tasks(monkeypatch):
|
||||
"""A saved binding waits for task cleanup, then affects the next task."""
|
||||
|
||||
class EndOfInbox(Exception):
|
||||
pass
|
||||
|
||||
second_boundary_entered = threading.Event()
|
||||
|
||||
class BoundaryInbox:
|
||||
def __init__(self):
|
||||
self.events = [
|
||||
{"type": "user_message", "id": "first"},
|
||||
{"type": "user_message", "id": "second"},
|
||||
]
|
||||
|
||||
def get(self):
|
||||
if not self.events:
|
||||
raise EndOfInbox
|
||||
if len(self.events) == 1:
|
||||
second_boundary_entered.set()
|
||||
return self.events.pop(0)
|
||||
|
||||
def task_done(self):
|
||||
pass
|
||||
|
||||
def empty(self):
|
||||
# Keep each event as a separate task boundary for this regression.
|
||||
return True
|
||||
|
||||
selected = {"llmNo": 1}
|
||||
timeline: list[tuple[str, int]] = []
|
||||
allow_first_cleanup = threading.Event()
|
||||
first_cleanup_done = threading.Event()
|
||||
|
||||
class TaskAgent(FakeAgent):
|
||||
def __init__(self):
|
||||
super().__init__(["zero", "one", "two"])
|
||||
self.inc_out = False
|
||||
self.task_models: list[int] = []
|
||||
self.task_queue: queue.Queue = queue.Queue()
|
||||
self.cleanup_threads: list[threading.Thread] = []
|
||||
|
||||
def next_llm(self, no: int):
|
||||
super().next_llm(no)
|
||||
timeline.append(("select", self.llm_no))
|
||||
|
||||
def put_task(self, _prompt: str, source: str):
|
||||
assert source == "conductor"
|
||||
self.task_models.append(self.llm_no)
|
||||
timeline.append(("submit", self.llm_no))
|
||||
if len(self.task_models) == 1:
|
||||
# Simulate save_config while task one is already submitted. The
|
||||
# display queue reports done before the agent runner persists its
|
||||
# final state and calls task_done().
|
||||
selected["llmNo"] = 2
|
||||
assert self.llm_no == 1
|
||||
self.task_queue.put("first")
|
||||
|
||||
def finish_first_task():
|
||||
assert allow_first_cleanup.wait(timeout=2)
|
||||
assert self.task_queue.get_nowait() == "first"
|
||||
self.task_queue.task_done()
|
||||
first_cleanup_done.set()
|
||||
|
||||
cleanup = threading.Thread(target=finish_first_task)
|
||||
cleanup.start()
|
||||
self.cleanup_threads.append(cleanup)
|
||||
else:
|
||||
self.task_queue.put("second")
|
||||
assert self.task_queue.get_nowait() == "second"
|
||||
self.task_queue.task_done()
|
||||
output: queue.Queue = queue.Queue()
|
||||
output.put({"done": True, "turn": len(self.task_models)})
|
||||
return output
|
||||
|
||||
instance = conductor.Conductor()
|
||||
instance.agent = TaskAgent()
|
||||
instance.inbox = BoundaryInbox()
|
||||
payloads: list[dict] = []
|
||||
monkeypatch.setattr(
|
||||
conductor,
|
||||
"_settings_doc",
|
||||
lambda: {"conductor": {"llmNo": selected["llmNo"]}},
|
||||
)
|
||||
monkeypatch.setattr(conductor, "schedule_broadcast", payloads.append)
|
||||
monkeypatch.setattr(conductor, "start_agent_runner", lambda *_args: None)
|
||||
monkeypatch.setattr(conductor.time, "sleep", lambda _seconds: None)
|
||||
monkeypatch.setattr(instance, "_build_prompt", lambda events: events[0]["id"])
|
||||
|
||||
run_errors: list[BaseException] = []
|
||||
|
||||
def run_loop():
|
||||
try:
|
||||
instance._run()
|
||||
except EndOfInbox:
|
||||
pass
|
||||
except BaseException as error: # surfaced below in the test thread
|
||||
run_errors.append(error)
|
||||
|
||||
runner = threading.Thread(target=run_loop)
|
||||
runner.start()
|
||||
assert second_boundary_entered.wait(timeout=2)
|
||||
|
||||
# The display `done` marker has already arrived, but queue cleanup is still
|
||||
# blocked. Model two must not be applied or submitted yet.
|
||||
assert instance.agent.task_models == [1]
|
||||
assert timeline == [("select", 1), ("submit", 1)]
|
||||
|
||||
allow_first_cleanup.set()
|
||||
assert first_cleanup_done.wait(timeout=2)
|
||||
runner.join(timeout=2)
|
||||
for cleanup in instance.agent.cleanup_threads:
|
||||
cleanup.join(timeout=2)
|
||||
|
||||
assert not runner.is_alive()
|
||||
assert run_errors == []
|
||||
|
||||
assert instance.agent.task_models == [1, 2]
|
||||
assert timeline == [
|
||||
("select", 1),
|
||||
("submit", 1),
|
||||
("select", 2),
|
||||
("submit", 2),
|
||||
]
|
||||
assert instance.model_snapshot() == {
|
||||
"configured": 2,
|
||||
"effective": 2,
|
||||
"fallbackReason": None,
|
||||
"current": "two",
|
||||
"running": False,
|
||||
}
|
||||
assert [item["model"]["effective"] for item in payloads] == [1, 1, 2, 2]
|
||||
assert [item["model"]["running"] for item in payloads] == [True, False, True, False]
|
||||
|
||||
|
||||
def test_conductor_defers_events_without_models_and_recovers(monkeypatch):
|
||||
class EndOfInbox(Exception):
|
||||
pass
|
||||
|
||||
ready = {"value": False}
|
||||
|
||||
class BoundaryInbox:
|
||||
def __init__(self):
|
||||
self.events = [
|
||||
{"type": "user_message", "id": "deferred"},
|
||||
{"type": "user_message", "id": "recovery"},
|
||||
]
|
||||
|
||||
def get(self):
|
||||
if not self.events:
|
||||
raise EndOfInbox
|
||||
if len(self.events) == 1:
|
||||
ready["value"] = True
|
||||
return self.events.pop(0)
|
||||
|
||||
def task_done(self):
|
||||
pass
|
||||
|
||||
def empty(self):
|
||||
return True
|
||||
|
||||
class RecoveringAgent:
|
||||
def __init__(self):
|
||||
self.inc_out = False
|
||||
self.llmclients: list[Client] = []
|
||||
self.llm_no = 0
|
||||
self.task_queue: queue.Queue = queue.Queue()
|
||||
self.submissions: list[str] = []
|
||||
|
||||
def load_llm_sessions(self):
|
||||
if ready["value"] and not self.llmclients:
|
||||
self.llmclients = [Client("recovered")]
|
||||
|
||||
def next_llm(self, no: int):
|
||||
self.llm_no = no
|
||||
self.llmclient = self.llmclients[no]
|
||||
|
||||
def get_llm_name(self, client=None, model=False):
|
||||
client = client or self.llmclient
|
||||
return client.backend.model if model else client.backend.name
|
||||
|
||||
def put_task(self, prompt: str, source: str):
|
||||
assert source == "conductor"
|
||||
self.submissions.append(prompt)
|
||||
output: queue.Queue = queue.Queue()
|
||||
output.put({"done": True, "turn": 1})
|
||||
return output
|
||||
|
||||
instance = conductor.Conductor()
|
||||
instance.agent = RecoveringAgent()
|
||||
instance.inbox = BoundaryInbox()
|
||||
payloads: list[dict] = []
|
||||
monkeypatch.setattr(conductor, "_settings_doc", lambda: {})
|
||||
monkeypatch.setattr(conductor, "schedule_broadcast", payloads.append)
|
||||
monkeypatch.setattr(conductor, "start_agent_runner", lambda *_args: None)
|
||||
monkeypatch.setattr(conductor.time, "sleep", lambda _seconds: None)
|
||||
monkeypatch.setattr(instance, "_build_prompt", lambda events: ",".join(e["id"] for e in events))
|
||||
|
||||
with pytest.raises(EndOfInbox):
|
||||
instance._run()
|
||||
|
||||
assert instance.agent.submissions == ["deferred,recovery"]
|
||||
model_payloads = [item["model"] for item in payloads if item["type"] == "model"]
|
||||
assert model_payloads[0]["effective"] is None
|
||||
assert model_payloads[0]["fallbackReason"] == "no_models"
|
||||
assert model_payloads[0]["running"] is False
|
||||
assert [model["running"] for model in model_payloads[1:]] == [True, False]
|
||||
assert any(
|
||||
item["item"]["text"].startswith("Conductor paused: no usable model")
|
||||
for item in payloads
|
||||
if item["type"] == "log"
|
||||
)
|
||||
|
||||
|
||||
def test_parallel_worker_model_selection_keeps_agents_isolated():
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
agents = [FakeAgent(["zero", "one", "two", "three"]) for _ in range(10)]
|
||||
requested = [i % 4 for i in range(10)]
|
||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||
selected = list(executor.map(
|
||||
lambda pair: conductor._select_llm(pair[0], pair[1]),
|
||||
zip(agents, requested),
|
||||
))
|
||||
|
||||
assert selected == [True] * 10
|
||||
assert [agent.llm_no for agent in agents] == requested
|
||||
assert [agent.next_llm_calls for agent in agents] == [[no] for no in requested]
|
||||
@@ -0,0 +1,444 @@
|
||||
"""Regression tests for the per-call token ledger."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
_SPEC = importlib.util.spec_from_file_location(
|
||||
"desktop_cost_tracker_under_test", ROOT / "frontends" / "cost_tracker.py"
|
||||
)
|
||||
assert _SPEC and _SPEC.loader
|
||||
cost_tracker = importlib.util.module_from_spec(_SPEC)
|
||||
sys.modules[_SPEC.name] = cost_tracker
|
||||
_SPEC.loader.exec_module(cost_tracker)
|
||||
|
||||
|
||||
def _close_ledger() -> None:
|
||||
fd = cost_tracker._ledger_fd
|
||||
if fd is not None:
|
||||
fd.close()
|
||||
cost_tracker._ledger_fd = None
|
||||
cost_tracker._ledger_path = None
|
||||
|
||||
|
||||
def setup_function() -> None:
|
||||
_close_ledger()
|
||||
|
||||
|
||||
def teardown_function() -> None:
|
||||
_close_ledger()
|
||||
|
||||
|
||||
def test_uninitialized_append_returns_before_clock_json_and_lock(monkeypatch):
|
||||
class ForbiddenLock:
|
||||
def __enter__(self):
|
||||
raise AssertionError("uninitialized TUI path acquired the ledger lock")
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(
|
||||
cost_tracker.json,
|
||||
"dumps",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("uninitialized TUI path serialized JSON")
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cost_tracker.time,
|
||||
"time",
|
||||
lambda: (_ for _ in ()).throw(
|
||||
AssertionError("uninitialized TUI path read the clock")
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(cost_tracker, "_ledger_lock", ForbiddenLock())
|
||||
|
||||
cost_tracker._append_ledger("tui-main", 1, 2, 3, 4)
|
||||
|
||||
|
||||
def test_public_tracker_and_context_interfaces_keep_tui_semantics(monkeypatch):
|
||||
cost_tracker._trackers.clear()
|
||||
tracker = cost_tracker.get("tui-main")
|
||||
assert tracker is cost_tracker.get("tui-main")
|
||||
tracker.requests = 2
|
||||
tracker.input = 10
|
||||
tracker.output = 4
|
||||
tracker.cache_create = 3
|
||||
tracker.cache_read = 5
|
||||
tracker.started_at = 100.0
|
||||
monkeypatch.setattr(cost_tracker.time, "time", lambda: 106.5)
|
||||
|
||||
assert tracker.total_input_side() == 18
|
||||
assert tracker.total_tokens() == 22
|
||||
assert tracker.cache_hit_rate() == pytest.approx(5 / 18 * 100)
|
||||
assert tracker.elapsed_seconds() == 6.5
|
||||
snapshot = cost_tracker.all_trackers()
|
||||
assert snapshot == {"tui-main": tracker}
|
||||
snapshot.clear()
|
||||
assert cost_tracker.all_trackers() == {"tui-main": tracker}
|
||||
|
||||
backend = types.SimpleNamespace(
|
||||
context_win="200",
|
||||
history=[{"role": "user", "content": "你好"}],
|
||||
)
|
||||
assert cost_tracker.context_window_chars(backend) == 600
|
||||
assert cost_tracker.current_input_chars(backend) == len(
|
||||
json.dumps(backend.history[0], ensure_ascii=False)
|
||||
)
|
||||
assert cost_tracker.context_window_chars(types.SimpleNamespace(context_win="bad")) == 0
|
||||
|
||||
|
||||
def test_install_remains_idempotent_without_desktop_ledger(monkeypatch):
|
||||
original_calls = []
|
||||
printed = []
|
||||
fake_llmcore = types.ModuleType("llmcore")
|
||||
fake_llmcore._record_usage = lambda value, mode: original_calls.append((value, mode))
|
||||
monkeypatch.setitem(sys.modules, "llmcore", fake_llmcore)
|
||||
monkeypatch.setattr(cost_tracker, "_INSTALLED", False)
|
||||
monkeypatch.setattr(cost_tracker, "_trackers", {})
|
||||
monkeypatch.setattr("builtins.print", lambda *args, **_kwargs: printed.append(args))
|
||||
|
||||
cost_tracker.install()
|
||||
wrapped_record = fake_llmcore._record_usage
|
||||
wrapped_print = fake_llmcore.print
|
||||
cost_tracker.install()
|
||||
|
||||
assert fake_llmcore._record_usage is wrapped_record
|
||||
assert fake_llmcore.print is wrapped_print
|
||||
fake_llmcore._record_usage(
|
||||
{"prompt_tokens": 7, "prompt_tokens_details": {"cached_tokens": 2}},
|
||||
"chat_completions",
|
||||
)
|
||||
fake_llmcore.print("[Output] tokens=3")
|
||||
tracker = cost_tracker.get(threading.current_thread().name)
|
||||
assert (tracker.requests, tracker.input, tracker.output, tracker.cache_read) == (1, 5, 3, 2)
|
||||
assert original_calls and printed == [("[Output] tokens=3",)]
|
||||
assert cost_tracker._ledger_path is None
|
||||
|
||||
|
||||
def test_append_persists_and_aggregates_per_session(tmp_path):
|
||||
cost_tracker.init_ledger(str(tmp_path))
|
||||
|
||||
cost_tracker._append_ledger("GA-sess-1", 10, 0, 2, 3)
|
||||
cost_tracker._append_ledger("GA-sess-1", 0, 7, 0, 0)
|
||||
cost_tracker._append_ledger("GA-sess-2", 4, 5, 0, 1)
|
||||
|
||||
entries = cost_tracker.read_ledger()
|
||||
assert len(entries) == 3
|
||||
aggregate = cost_tracker.aggregate_ledger()
|
||||
by_id = {entry["sessionId"]: entry for entry in aggregate["history"]}
|
||||
assert by_id["sess-1"]["input"] == 10
|
||||
assert by_id["sess-1"]["output"] == 7
|
||||
assert by_id["sess-1"]["cacheCreate"] == 2
|
||||
assert by_id["sess-1"]["cacheRead"] == 3
|
||||
assert aggregate["snap"]["GA-sess-2"] == {
|
||||
"input": 4,
|
||||
"output": 5,
|
||||
"cacheCreate": 0,
|
||||
"cacheRead": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_concurrent_appends_are_serialized(tmp_path, monkeypatch):
|
||||
cost_tracker.init_ledger(str(tmp_path))
|
||||
real_fd = cost_tracker._ledger_fd
|
||||
|
||||
class DetectConcurrentWrites:
|
||||
def __init__(self):
|
||||
self.active = 0
|
||||
self.overlapped = False
|
||||
|
||||
def write(self, value):
|
||||
self.active += 1
|
||||
if self.active > 1:
|
||||
self.overlapped = True
|
||||
time.sleep(0.01)
|
||||
result = real_fd.write(value)
|
||||
self.active -= 1
|
||||
return result
|
||||
|
||||
def flush(self):
|
||||
real_fd.flush()
|
||||
|
||||
def close(self):
|
||||
real_fd.close()
|
||||
|
||||
detector = DetectConcurrentWrites()
|
||||
monkeypatch.setattr(cost_tracker, "_ledger_fd", detector)
|
||||
threads = [
|
||||
threading.Thread(target=cost_tracker._append_ledger, args=(f"GA-{i}", 1, 0, 0, 0))
|
||||
for i in range(12)
|
||||
]
|
||||
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
assert detector.overlapped is False
|
||||
assert len(cost_tracker.read_ledger()) == 12
|
||||
|
||||
|
||||
def test_threshold_compaction_runs_during_process_lifetime(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(cost_tracker, "_COMPACT_THRESHOLD", 1)
|
||||
cost_tracker.init_ledger(str(tmp_path))
|
||||
|
||||
cost_tracker._append_ledger("GA-sess-1", 10, 7, 2, 3)
|
||||
|
||||
entries = cost_tracker.read_ledger()
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["_compacted"] is True
|
||||
|
||||
|
||||
def test_compaction_preserves_last_activity_timestamp(tmp_path):
|
||||
cost_tracker.init_ledger(str(tmp_path))
|
||||
ledger_path = tmp_path / "temp" / "token_ledger.jsonl"
|
||||
first_ts = 1_700_000_000.0
|
||||
second_ts = 1_700_000_100.0
|
||||
cost_tracker._ledger_fd.close()
|
||||
cost_tracker._ledger_fd = None
|
||||
ledger_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
json.dumps({"t": first_ts, "k": "GA-sess-1", "i": 10, "o": 0, "cc": 0, "cr": 0}),
|
||||
json.dumps({"t": second_ts, "k": "GA-sess-1", "i": 0, "o": 7, "cc": 0, "cr": 0}),
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
cost_tracker._ledger_fd = ledger_path.open("a", encoding="utf-8")
|
||||
|
||||
cost_tracker._compact_ledger()
|
||||
|
||||
history = cost_tracker.aggregate_ledger()["history"]
|
||||
assert history[0]["ts"] == second_ts
|
||||
|
||||
|
||||
def test_reinitializing_closes_the_previous_descriptor(tmp_path):
|
||||
cost_tracker.init_ledger(str(tmp_path / "one"))
|
||||
previous_fd = cost_tracker._ledger_fd
|
||||
|
||||
cost_tracker.init_ledger(str(tmp_path / "two"))
|
||||
|
||||
assert previous_fd.closed is True
|
||||
|
||||
|
||||
def test_init_migrates_legacy_snapshot_when_ledger_is_empty(tmp_path):
|
||||
temp_dir = tmp_path / "temp"
|
||||
temp_dir.mkdir()
|
||||
(temp_dir / "desktop_token_history.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"history": [
|
||||
{
|
||||
"sessionId": "sess-legacy",
|
||||
"title": "Legacy session",
|
||||
"input": 3,
|
||||
"output": 4,
|
||||
"cacheCreate": 5,
|
||||
"cacheRead": 6,
|
||||
"model": "legacy-model",
|
||||
"ts": 1_700_000_000.0,
|
||||
}
|
||||
],
|
||||
"snap": {
|
||||
"GA-sess-legacy": {
|
||||
"input": 30,
|
||||
"output": 40,
|
||||
"cacheCreate": 50,
|
||||
"cacheRead": 60,
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
cost_tracker.init_ledger(str(tmp_path))
|
||||
|
||||
aggregate = cost_tracker.aggregate_ledger()
|
||||
assert aggregate["snap"]["GA-sess-legacy"] == {
|
||||
"input": 30,
|
||||
"output": 40,
|
||||
"cacheCreate": 50,
|
||||
"cacheRead": 60,
|
||||
}
|
||||
assert aggregate["history"][0]["title"] == "Legacy session"
|
||||
assert aggregate["history"][0]["model"] == "legacy-model"
|
||||
assert aggregate["history"][0]["ts"] == 1_700_000_000.0
|
||||
|
||||
|
||||
def test_init_does_not_migrate_legacy_snapshot_into_nonempty_ledger(tmp_path):
|
||||
temp_dir = tmp_path / "temp"
|
||||
temp_dir.mkdir()
|
||||
(temp_dir / "token_ledger.jsonl").write_text(
|
||||
json.dumps({"t": 2.0, "k": "GA-current", "i": 1, "o": 0, "cc": 0, "cr": 0}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(temp_dir / "desktop_token_history.json").write_text(
|
||||
json.dumps({"snap": {"GA-legacy": {"input": 99, "output": 0, "cacheCreate": 0, "cacheRead": 0}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
cost_tracker.init_ledger(str(tmp_path))
|
||||
|
||||
assert set(cost_tracker.aggregate_ledger()["snap"]) == {"GA-current"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("api_mode", "usage", "expected"),
|
||||
[
|
||||
(
|
||||
"messages",
|
||||
{
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 1,
|
||||
"cache_creation_input_tokens": 2,
|
||||
"cache_read_input_tokens": 3,
|
||||
},
|
||||
{"input": 10, "output": 7, "cacheCreate": 2, "cacheRead": 3},
|
||||
),
|
||||
(
|
||||
"chat_completions",
|
||||
{"prompt_tokens": 13, "prompt_tokens_details": {"cached_tokens": 3}},
|
||||
{"input": 10, "output": 7, "cacheCreate": 0, "cacheRead": 3},
|
||||
),
|
||||
(
|
||||
"responses",
|
||||
{"input_tokens": 13, "input_tokens_details": {"cached_tokens": 3}},
|
||||
{"input": 10, "output": 7, "cacheCreate": 0, "cacheRead": 3},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_install_hooks_append_each_api_mode(tmp_path, monkeypatch, api_mode, usage, expected):
|
||||
original_calls = []
|
||||
fake_llmcore = types.ModuleType("llmcore")
|
||||
fake_llmcore._record_usage = lambda value, mode: original_calls.append((value, mode))
|
||||
monkeypatch.setitem(sys.modules, "llmcore", fake_llmcore)
|
||||
monkeypatch.setattr(cost_tracker, "_INSTALLED", False)
|
||||
cost_tracker._trackers.clear()
|
||||
cost_tracker.init_ledger(str(tmp_path))
|
||||
|
||||
cost_tracker.install()
|
||||
fake_llmcore._record_usage(usage, api_mode)
|
||||
fake_llmcore.print("[Output] tokens=7")
|
||||
|
||||
snapshot = next(iter(cost_tracker.aggregate_ledger()["snap"].values()))
|
||||
assert snapshot == expected
|
||||
assert original_calls == [(usage, api_mode)]
|
||||
|
||||
|
||||
def test_concurrent_appends_remain_exact_across_compactions(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(cost_tracker, "_COMPACT_THRESHOLD", 1_000)
|
||||
cost_tracker.init_ledger(str(tmp_path))
|
||||
per_thread = 20
|
||||
|
||||
def append_session(key: str) -> None:
|
||||
for _ in range(per_thread):
|
||||
cost_tracker._append_ledger(key, 1, 2, 3, 4)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=append_session, args=(key,))
|
||||
for key in (f"GA-sess-{i}" for i in range(8))
|
||||
]
|
||||
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
aggregate = cost_tracker.aggregate_ledger()
|
||||
assert len(aggregate["snap"]) == 8
|
||||
for snapshot in aggregate["snap"].values():
|
||||
assert snapshot == {
|
||||
"input": per_thread,
|
||||
"output": per_thread * 2,
|
||||
"cacheCreate": per_thread * 3,
|
||||
"cacheRead": per_thread * 4,
|
||||
}
|
||||
|
||||
|
||||
def test_corrupt_and_truncated_lines_are_skipped(tmp_path):
|
||||
cost_tracker.init_ledger(str(tmp_path))
|
||||
cost_tracker._append_ledger("GA-valid", 3, 4, 5, 6)
|
||||
ledger_path = tmp_path / "temp" / "token_ledger.jsonl"
|
||||
cost_tracker._ledger_fd.write("not-json\n{\"truncated\":\n")
|
||||
cost_tracker._ledger_fd.flush()
|
||||
|
||||
assert cost_tracker.aggregate_ledger()["snap"] == {
|
||||
"GA-valid": {"input": 3, "output": 4, "cacheCreate": 5, "cacheRead": 6}
|
||||
}
|
||||
|
||||
|
||||
def test_compaction_uses_atomic_replace_and_keeps_descriptor_writable(tmp_path, monkeypatch):
|
||||
cost_tracker.init_ledger(str(tmp_path))
|
||||
cost_tracker._append_ledger("GA-valid", 3, 4, 0, 0)
|
||||
real_replace = cost_tracker.os.replace
|
||||
replacements = []
|
||||
|
||||
def observe_replace(source, target):
|
||||
replacements.append((Path(source), Path(target)))
|
||||
assert Path(source).name == "token_ledger.jsonl.tmp"
|
||||
assert Path(target).name == "token_ledger.jsonl"
|
||||
real_replace(source, target)
|
||||
|
||||
monkeypatch.setattr(cost_tracker.os, "replace", observe_replace)
|
||||
cost_tracker._compact_ledger()
|
||||
cost_tracker._append_ledger("GA-valid", 0, 2, 0, 0)
|
||||
|
||||
assert len(replacements) == 1
|
||||
assert cost_tracker.aggregate_ledger()["snap"]["GA-valid"]["output"] == 6
|
||||
|
||||
|
||||
def test_real_10mb_threshold_compacts_on_init(tmp_path):
|
||||
temp_dir = tmp_path / "temp"
|
||||
temp_dir.mkdir()
|
||||
ledger_path = temp_dir / "token_ledger.jsonl"
|
||||
valid = json.dumps({"t": 1, "k": "GA-valid", "i": 3, "o": 4, "cc": 0, "cr": 0}) + "\n"
|
||||
with ledger_path.open("wb") as ledger:
|
||||
ledger.write(valid.encode("utf-8"))
|
||||
ledger.truncate(cost_tracker._COMPACT_THRESHOLD)
|
||||
|
||||
cost_tracker.init_ledger(str(tmp_path))
|
||||
|
||||
assert ledger_path.stat().st_size < 1_000
|
||||
assert cost_tracker.aggregate_ledger()["snap"]["GA-valid"] == {
|
||||
"input": 3,
|
||||
"output": 4,
|
||||
"cacheCreate": 0,
|
||||
"cacheRead": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_repeated_init_migrates_legacy_snapshot_only_once(tmp_path):
|
||||
temp_dir = tmp_path / "temp"
|
||||
temp_dir.mkdir()
|
||||
(temp_dir / "desktop_token_history.json").write_text(
|
||||
json.dumps({
|
||||
"snap": {
|
||||
"GA-legacy": {"input": 7, "output": 8, "cacheCreate": 9, "cacheRead": 10}
|
||||
}
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
cost_tracker.init_ledger(str(tmp_path))
|
||||
cost_tracker.init_ledger(str(tmp_path))
|
||||
|
||||
assert cost_tracker.aggregate_ledger()["snap"]["GA-legacy"] == {
|
||||
"input": 7,
|
||||
"output": 8,
|
||||
"cacheCreate": 9,
|
||||
"cacheRead": 10,
|
||||
}
|
||||
assert len(cost_tracker.read_ledger()) == 1
|
||||
@@ -0,0 +1,674 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import stat
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import frontends.data_backup as data_backup
|
||||
from frontends.data_backup import (
|
||||
BACKUP_FORMAT_VERSION,
|
||||
BACKUP_SCHEMA,
|
||||
BackupFormatError,
|
||||
export_data_backup,
|
||||
inspect_import_source,
|
||||
materialize_import_source,
|
||||
merge_data_files,
|
||||
)
|
||||
|
||||
|
||||
def _seed_data(root: Path) -> None:
|
||||
(root / "memory" / "nested").mkdir(parents=True)
|
||||
(root / "memory" / "notes.md").write_text("memory", encoding="utf-8")
|
||||
(root / "memory" / "nested" / "facts.json").write_text("{}", encoding="utf-8")
|
||||
(root / "temp" / "model_responses").mkdir(parents=True)
|
||||
(root / "temp" / "model_responses" / "response.json").write_text("{}", encoding="utf-8")
|
||||
(root / "temp" / "desktop_sessions").mkdir(parents=True)
|
||||
(root / "temp" / "desktop_sessions" / "sess-one.json").write_text(
|
||||
json.dumps({"id": "sess-one", "title": "One", "messages": []}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "mykey.py").write_text("secret", encoding="utf-8")
|
||||
(root / "agentmain.py").write_text("code", encoding="utf-8")
|
||||
(root / "logs").mkdir()
|
||||
(root / "logs" / "bridge.log").write_text("private log", encoding="utf-8")
|
||||
|
||||
|
||||
class TestDataBackupExport:
|
||||
def test_exports_only_allowed_data_and_private_manifest(self, tmp_path: Path):
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
_seed_data(source)
|
||||
destination = tmp_path / "GenericAgent-data-backup.zip"
|
||||
|
||||
result = export_data_backup(str(source), str(destination), "localRepository")
|
||||
|
||||
assert result["content"] == {"memory": 2, "responses": 1, "sessions": 1}
|
||||
with zipfile.ZipFile(destination) as archive:
|
||||
names = set(archive.namelist())
|
||||
assert names == {
|
||||
"manifest.json",
|
||||
"memory/notes.md",
|
||||
"memory/nested/facts.json",
|
||||
"temp/model_responses/response.json",
|
||||
"temp/desktop_sessions/sess-one.json",
|
||||
}
|
||||
manifest = json.loads(archive.read("manifest.json"))
|
||||
assert manifest["schema"] == BACKUP_SCHEMA
|
||||
assert manifest["formatVersion"] == BACKUP_FORMAT_VERSION
|
||||
assert manifest["sourceMode"] == "localRepository"
|
||||
assert str(source) not in json.dumps(manifest)
|
||||
assert "mykey.py" not in names
|
||||
assert "agentmain.py" not in names
|
||||
assert "logs/bridge.log" not in names
|
||||
|
||||
def test_rejects_symlinks_instead_of_reading_through_them(self, tmp_path: Path):
|
||||
source = tmp_path / "source"
|
||||
(source / "memory").mkdir(parents=True)
|
||||
secret = tmp_path / "outside-secret.txt"
|
||||
secret.write_text("secret", encoding="utf-8")
|
||||
(source / "memory" / "linked.txt").symlink_to(secret)
|
||||
destination = tmp_path / "backup.zip"
|
||||
|
||||
with pytest.raises(ValueError, match="link or reparse point"):
|
||||
export_data_backup(str(source), str(destination), "included")
|
||||
|
||||
assert not destination.exists()
|
||||
|
||||
def test_rejects_destination_inside_exported_data_before_replacing_it(
|
||||
self, tmp_path: Path
|
||||
):
|
||||
source = tmp_path / "source"
|
||||
(source / "memory").mkdir(parents=True)
|
||||
destination = source / "memory" / "nested-backup.zip"
|
||||
destination.write_bytes(b"old-destination")
|
||||
|
||||
with pytest.raises(ValueError, match="protected application data"):
|
||||
export_data_backup(str(source), str(destination), "included")
|
||||
|
||||
assert destination.read_bytes() == b"old-destination"
|
||||
|
||||
def test_rejects_http_readable_destination_before_creating_it(self, tmp_path: Path):
|
||||
source = tmp_path / "source"
|
||||
(source / "memory").mkdir(parents=True)
|
||||
(source / "memory" / "one.md").write_text("one", encoding="utf-8")
|
||||
upload_root = source / "temp" / "desktop_uploads"
|
||||
upload_root.mkdir(parents=True)
|
||||
destination = upload_root / "exfil.zip"
|
||||
|
||||
with pytest.raises(ValueError, match="protected application data"):
|
||||
export_data_backup(
|
||||
str(source), str(destination), "included", forbidden_roots=(upload_root,)
|
||||
)
|
||||
|
||||
assert not destination.exists()
|
||||
|
||||
def test_export_enforces_the_same_entry_limit_as_import(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
source = tmp_path / "source"
|
||||
(source / "memory").mkdir(parents=True)
|
||||
(source / "memory" / "one.md").write_text("one", encoding="utf-8")
|
||||
destination = tmp_path / "backup.zip"
|
||||
destination.write_bytes(b"old")
|
||||
monkeypatch.setattr(data_backup, "MAX_ARCHIVE_ENTRIES", 1)
|
||||
|
||||
with pytest.raises(BackupFormatError, match="too many files"):
|
||||
export_data_backup(str(source), str(destination), "included")
|
||||
|
||||
assert destination.read_bytes() == b"old"
|
||||
|
||||
def test_export_enforces_the_same_size_limit_as_import(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
source = tmp_path / "source"
|
||||
(source / "memory").mkdir(parents=True)
|
||||
(source / "memory" / "one.md").write_text("one", encoding="utf-8")
|
||||
destination = tmp_path / "backup.zip"
|
||||
destination.write_bytes(b"old")
|
||||
monkeypatch.setattr(data_backup, "MAX_ARCHIVE_UNCOMPRESSED_BYTES", 1)
|
||||
|
||||
with pytest.raises(BackupFormatError, match="too large"):
|
||||
export_data_backup(str(source), str(destination), "included")
|
||||
|
||||
assert destination.read_bytes() == b"old"
|
||||
|
||||
def test_export_revalidates_written_zip_before_replacing_destination(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
source = tmp_path / "source"
|
||||
(source / "memory").mkdir(parents=True)
|
||||
data_file = source / "memory" / "one.md"
|
||||
data_file.write_text("one", encoding="utf-8")
|
||||
destination = tmp_path / "backup.zip"
|
||||
destination.write_bytes(b"old")
|
||||
monkeypatch.setattr(data_backup, "MAX_ARCHIVE_UNCOMPRESSED_BYTES", 10_000)
|
||||
original_write = zipfile.ZipFile.write
|
||||
|
||||
def grow_then_write(archive, filename, arcname=None, *args, **kwargs):
|
||||
path = Path(filename)
|
||||
if path == data_file:
|
||||
path.write_bytes(b"x" * 20_000)
|
||||
return original_write(archive, filename, arcname, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(zipfile.ZipFile, "write", grow_then_write)
|
||||
|
||||
with pytest.raises(BackupFormatError, match="too large"):
|
||||
export_data_backup(str(source), str(destination), "included")
|
||||
|
||||
assert destination.read_bytes() == b"old"
|
||||
assert not list(tmp_path.glob(".backup.*.tmp"))
|
||||
|
||||
|
||||
class TestDataBackupInspection:
|
||||
def test_inspects_generated_backup(self, tmp_path: Path):
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
_seed_data(source)
|
||||
destination = tmp_path / "backup.zip"
|
||||
export_data_backup(str(source), str(destination), "included")
|
||||
|
||||
result = inspect_import_source(str(destination))
|
||||
|
||||
assert result["sourceType"] == "backupZip"
|
||||
assert result["sourceMode"] == "included"
|
||||
assert result["content"]["sessions"] == 1
|
||||
|
||||
def test_rejects_traversal_before_extraction(self, tmp_path: Path):
|
||||
destination = tmp_path / "traversal.zip"
|
||||
manifest = {
|
||||
"schema": BACKUP_SCHEMA,
|
||||
"formatVersion": BACKUP_FORMAT_VERSION,
|
||||
"exportedAt": "2026-08-22T00:00:00Z",
|
||||
"sourceMode": "included",
|
||||
"content": {"memory": 0, "responses": 0, "sessions": 0},
|
||||
}
|
||||
with zipfile.ZipFile(destination, "w") as archive:
|
||||
archive.writestr("manifest.json", json.dumps(manifest))
|
||||
archive.writestr("../outside.txt", "escape")
|
||||
|
||||
with pytest.raises(BackupFormatError, match="invalid backup entry path"):
|
||||
inspect_import_source(str(destination))
|
||||
|
||||
def test_rejects_symlink_entries(self, tmp_path: Path):
|
||||
destination = tmp_path / "link.zip"
|
||||
manifest = {
|
||||
"schema": BACKUP_SCHEMA,
|
||||
"formatVersion": BACKUP_FORMAT_VERSION,
|
||||
"exportedAt": "2026-08-22T00:00:00Z",
|
||||
"sourceMode": "included",
|
||||
"content": {"memory": 1, "responses": 0, "sessions": 0},
|
||||
}
|
||||
link = zipfile.ZipInfo("memory/link")
|
||||
link.create_system = 3
|
||||
link.external_attr = (stat.S_IFLNK | 0o777) << 16
|
||||
with zipfile.ZipFile(destination, "w") as archive:
|
||||
archive.writestr("manifest.json", json.dumps(manifest))
|
||||
archive.writestr(link, "../../secret")
|
||||
|
||||
with pytest.raises(BackupFormatError, match="contains links"):
|
||||
inspect_import_source(str(destination))
|
||||
|
||||
def test_rejects_manifest_count_mismatch(self, tmp_path: Path):
|
||||
destination = tmp_path / "mismatch.zip"
|
||||
manifest = {
|
||||
"schema": BACKUP_SCHEMA,
|
||||
"formatVersion": BACKUP_FORMAT_VERSION,
|
||||
"exportedAt": "2026-08-22T00:00:00Z",
|
||||
"sourceMode": "included",
|
||||
"content": {"memory": 2, "responses": 0, "sessions": 0},
|
||||
}
|
||||
with zipfile.ZipFile(destination, "w") as archive:
|
||||
archive.writestr("manifest.json", json.dumps(manifest))
|
||||
archive.writestr("memory/one.md", "one")
|
||||
|
||||
with pytest.raises(BackupFormatError, match="summary"):
|
||||
inspect_import_source(str(destination))
|
||||
|
||||
def test_rejects_duplicate_paths_even_when_case_differs(self, tmp_path: Path):
|
||||
destination = tmp_path / "duplicates.zip"
|
||||
manifest = {
|
||||
"schema": BACKUP_SCHEMA,
|
||||
"formatVersion": BACKUP_FORMAT_VERSION,
|
||||
"exportedAt": "2026-08-22T00:00:00Z",
|
||||
"sourceMode": "included",
|
||||
"content": {"memory": 2, "responses": 0, "sessions": 0},
|
||||
}
|
||||
with zipfile.ZipFile(destination, "w") as archive:
|
||||
archive.writestr("manifest.json", json.dumps(manifest))
|
||||
archive.writestr("memory/Note.md", "one")
|
||||
archive.writestr("memory/note.md", "two")
|
||||
|
||||
with pytest.raises(BackupFormatError, match="duplicate"):
|
||||
inspect_import_source(str(destination))
|
||||
|
||||
def test_accepts_session_only_legacy_folder(self, tmp_path: Path):
|
||||
source = tmp_path / "legacy"
|
||||
(source / "temp").mkdir(parents=True)
|
||||
(source / "temp" / "desktop_sessions.json").write_text(
|
||||
json.dumps([{"id": "sess-only", "messages": [], "msg_seq": 0}]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = inspect_import_source(str(source))
|
||||
|
||||
assert result["sourceType"] == "legacyFolder"
|
||||
assert result["content"] == {"memory": 0, "responses": 0, "sessions": 1}
|
||||
|
||||
def test_rejects_empty_legacy_folder_and_empty_backup(self, tmp_path: Path):
|
||||
source = tmp_path / "legacy"
|
||||
(source / "temp").mkdir(parents=True)
|
||||
(source / "temp" / "desktop_sessions.json").write_text("[]", encoding="utf-8")
|
||||
with pytest.raises(BackupFormatError, match="no importable data"):
|
||||
inspect_import_source(str(source))
|
||||
|
||||
backup = tmp_path / "empty.zip"
|
||||
manifest = {
|
||||
"schema": BACKUP_SCHEMA,
|
||||
"formatVersion": BACKUP_FORMAT_VERSION,
|
||||
"exportedAt": "2026-08-22T00:00:00Z",
|
||||
"sourceMode": "included",
|
||||
"content": {"memory": 0, "responses": 0, "sessions": 0},
|
||||
}
|
||||
with zipfile.ZipFile(backup, "w") as archive:
|
||||
archive.writestr("manifest.json", json.dumps(manifest))
|
||||
with pytest.raises(BackupFormatError, match="no importable data"):
|
||||
inspect_import_source(str(backup))
|
||||
|
||||
def test_accepts_generated_sessions_only_zip(self, tmp_path: Path):
|
||||
source = tmp_path / "source"
|
||||
sessions = source / "temp" / "desktop_sessions"
|
||||
sessions.mkdir(parents=True)
|
||||
(sessions / "sess-only.json").write_text(
|
||||
json.dumps({"id": "sess-only", "messages": [], "msg_seq": 0}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
backup = tmp_path / "sessions-only.zip"
|
||||
|
||||
export_data_backup(str(source), str(backup), "included")
|
||||
inspection = inspect_import_source(str(backup))
|
||||
|
||||
assert inspection["content"] == {"memory": 0, "responses": 0, "sessions": 1}
|
||||
|
||||
|
||||
class TestDataBackupImport:
|
||||
def test_sessions_only_backup_round_trips(self, tmp_path: Path):
|
||||
source = tmp_path / "source"
|
||||
sessions = source / "temp" / "desktop_sessions"
|
||||
sessions.mkdir(parents=True)
|
||||
(sessions / "sess-only.json").write_text(
|
||||
json.dumps({"id": "sess-only", "messages": [], "msg_seq": 0}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
backup = tmp_path / "sessions-only.zip"
|
||||
export_data_backup(str(source), str(backup), "included")
|
||||
target = tmp_path / "target"
|
||||
target.mkdir()
|
||||
|
||||
with materialize_import_source(str(backup)) as extracted:
|
||||
result = merge_data_files(str(extracted), str(target))
|
||||
|
||||
assert result["sessionsAdded"] == 1
|
||||
assert result["memoryCopied"] == 0
|
||||
assert (target / "temp" / "desktop_sessions" / "sess-only.json").is_file()
|
||||
|
||||
def test_materializes_backup_and_applies_the_full_merge_contract(self, tmp_path: Path):
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
_seed_data(source)
|
||||
backup = tmp_path / "backup.zip"
|
||||
export_data_backup(str(source), str(backup), "included")
|
||||
|
||||
target = tmp_path / "target"
|
||||
(target / "memory").mkdir(parents=True)
|
||||
(target / "memory" / "notes.md").write_text("current", encoding="utf-8")
|
||||
with materialize_import_source(str(backup)) as extracted:
|
||||
result = merge_data_files(str(extracted), str(target))
|
||||
assert (extracted / "temp" / "desktop_sessions" / "sess-one.json").is_file()
|
||||
|
||||
assert result.keys() >= {
|
||||
"ok",
|
||||
"memoryCopied",
|
||||
"responsesCopied",
|
||||
"responsesSkipped",
|
||||
"sessionsAdded",
|
||||
"sessionsSkipped",
|
||||
"sessionsFileFound",
|
||||
"backupDir",
|
||||
}
|
||||
assert result["memoryCopied"] == 2
|
||||
assert result["memorySkipped"] == 0
|
||||
assert result["responsesCopied"] == 1
|
||||
assert result["sessionsAdded"] == 1
|
||||
assert result["sessionsSkipped"] == 0
|
||||
assert result["sessionsFileFound"] is True
|
||||
assert (target / "memory" / "notes.md").read_text(encoding="utf-8") == "memory"
|
||||
assert (target / "memory" / "nested" / "facts.json").is_file()
|
||||
assert (target / "temp" / "desktop_sessions" / "sess-one.json").is_file()
|
||||
backup_dir = Path(result["backupDir"])
|
||||
assert backup_dir.is_dir()
|
||||
assert (backup_dir / "memory" / "notes.md").read_text(encoding="utf-8") == "current"
|
||||
|
||||
def test_memory_is_source_wins_responses_are_add_only(self, tmp_path: Path):
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
(source / "memory").mkdir(parents=True)
|
||||
(source / "memory" / "same.md").write_text("new", encoding="utf-8")
|
||||
(source / "memory" / "added.md").write_text("added", encoding="utf-8")
|
||||
(source / "temp" / "model_responses").mkdir(parents=True)
|
||||
(source / "temp" / "model_responses" / "same.json").write_text("new", encoding="utf-8")
|
||||
(source / "temp" / "model_responses" / "added.json").write_text("added", encoding="utf-8")
|
||||
(target / "memory").mkdir(parents=True)
|
||||
(target / "memory" / "same.md").write_text("old", encoding="utf-8")
|
||||
(target / "temp" / "model_responses").mkdir(parents=True)
|
||||
(target / "temp" / "model_responses" / "same.json").write_text("old", encoding="utf-8")
|
||||
|
||||
result = merge_data_files(str(source), str(target))
|
||||
|
||||
assert result["memoryCopied"] == 2
|
||||
assert result["responsesCopied"] == 1
|
||||
assert result["responsesSkipped"] == 1
|
||||
assert (target / "memory" / "same.md").read_text(encoding="utf-8") == "new"
|
||||
assert (target / "memory" / "added.md").read_text(encoding="utf-8") == "added"
|
||||
assert (target / "temp" / "model_responses" / "same.json").read_text(encoding="utf-8") == "old"
|
||||
assert (target / "temp" / "model_responses" / "added.json").read_text(encoding="utf-8") == "added"
|
||||
assert (Path(result["backupDir"]) / "memory" / "same.md").read_text(encoding="utf-8") == "old"
|
||||
|
||||
def test_sessions_dedupe_by_desktop_id_across_new_and_legacy_stores(self, tmp_path: Path):
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
(source / "memory").mkdir(parents=True)
|
||||
source_sessions = source / "temp" / "desktop_sessions"
|
||||
source_sessions.mkdir(parents=True)
|
||||
(source_sessions / "existing.json").write_text(
|
||||
json.dumps({"id": "sess-existing", "messages": []}), encoding="utf-8"
|
||||
)
|
||||
(source_sessions / "new.json").write_text(
|
||||
json.dumps({"id": "sess-new", "messages": []}), encoding="utf-8"
|
||||
)
|
||||
(source_sessions / "tui.json").write_text(
|
||||
json.dumps({"id": "tui_worker", "messages": []}), encoding="utf-8"
|
||||
)
|
||||
(source_sessions / "corrupt.json").write_text("{bad", encoding="utf-8")
|
||||
(source / "temp" / "desktop_sessions.json").write_text(
|
||||
json.dumps([
|
||||
{"id": "sess-new", "messages": []},
|
||||
{"id": "sess-legacy", "messages": []},
|
||||
{"id": "../../escape", "messages": []},
|
||||
]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
target_sessions = target / "temp" / "desktop_sessions"
|
||||
target_sessions.mkdir(parents=True)
|
||||
(target_sessions / "different-name.json").write_text(
|
||||
json.dumps({"id": "sess-existing", "messages": []}), encoding="utf-8"
|
||||
)
|
||||
|
||||
result = merge_data_files(
|
||||
str(source), str(target), existing_session_ids={"sess-in-memory"}
|
||||
)
|
||||
|
||||
assert result["sessionsAdded"] == 2
|
||||
assert result["sessionsSkipped"] == 5
|
||||
assert result["sessionsFileFound"] is True
|
||||
assert (target_sessions / "sess-new.json").is_file()
|
||||
assert (target_sessions / "sess-legacy.json").is_file()
|
||||
assert not (tmp_path / "escape.json").exists()
|
||||
|
||||
def test_invalid_session_schema_is_skipped_and_valid_record_is_canonical(
|
||||
self, tmp_path: Path
|
||||
):
|
||||
source = tmp_path / "source"
|
||||
sessions = source / "temp" / "desktop_sessions"
|
||||
sessions.mkdir(parents=True)
|
||||
records = {
|
||||
"int-id.json": {"id": 7, "messages": [], "msg_seq": 0},
|
||||
"nan-time.json": {
|
||||
"id": "sess-nan",
|
||||
"messages": [],
|
||||
"msg_seq": 0,
|
||||
"updated_at": float("nan"),
|
||||
},
|
||||
"bad-seq.json": {"id": "sess-seq", "messages": [], "msg_seq": -1},
|
||||
"bad-messages.json": {
|
||||
"id": "sess-msg",
|
||||
"messages": ["not-an-object"],
|
||||
"msg_seq": 0,
|
||||
},
|
||||
"valid.json": {
|
||||
"id": "sess-valid",
|
||||
"title": "Valid",
|
||||
"messages": [{"id": 1, "role": "user", "content": "hi"}],
|
||||
"msg_seq": 1,
|
||||
"created_at": 10,
|
||||
"updated_at": 11.5,
|
||||
"llm_history": [{"role": "user", "content": "hi"}],
|
||||
"unknown": "drop-me",
|
||||
},
|
||||
}
|
||||
for name, record in records.items():
|
||||
(sessions / name).write_text(json.dumps(record), encoding="utf-8")
|
||||
target = tmp_path / "target"
|
||||
target.mkdir()
|
||||
|
||||
result = merge_data_files(str(source), str(target))
|
||||
|
||||
assert result["sessionsAdded"] == 1
|
||||
assert result["sessionsSkipped"] == 4
|
||||
document = json.loads(
|
||||
(target / "temp" / "desktop_sessions" / "sess-valid.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
assert document["id"] == "sess-valid"
|
||||
assert document["updated_at"] == 11.5
|
||||
assert document["messages"][0]["role"] == "user"
|
||||
assert "unknown" not in document
|
||||
|
||||
def test_mocked_windows_reparse_source_is_rejected_before_read(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
source = tmp_path / "source"
|
||||
memory = source / "memory"
|
||||
memory.mkdir(parents=True)
|
||||
(memory / "secret.md").write_text("secret", encoding="utf-8")
|
||||
destination = tmp_path / "backup.zip"
|
||||
real_check = data_backup._is_link_or_reparse
|
||||
|
||||
monkeypatch.setattr(
|
||||
data_backup,
|
||||
"_is_link_or_reparse",
|
||||
lambda path: Path(path) == memory or real_check(Path(path)),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="safe directory"):
|
||||
export_data_backup(str(source), str(destination), "included")
|
||||
assert not destination.exists()
|
||||
|
||||
def test_mocked_windows_reparse_target_ancestor_is_rejected_before_write(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
(source / "memory").mkdir(parents=True)
|
||||
(source / "memory" / "new.md").write_text("new", encoding="utf-8")
|
||||
target_memory = target / "memory"
|
||||
target_memory.mkdir(parents=True)
|
||||
(target_memory / "old.md").write_text("old", encoding="utf-8")
|
||||
real_check = data_backup._is_link_or_reparse
|
||||
|
||||
monkeypatch.setattr(
|
||||
data_backup,
|
||||
"_is_link_or_reparse",
|
||||
lambda path: Path(path) == target_memory or real_check(Path(path)),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="safe directory"):
|
||||
merge_data_files(str(source), str(target))
|
||||
assert (target_memory / "old.md").read_text(encoding="utf-8") == "old"
|
||||
assert not (target_memory / "new.md").exists()
|
||||
|
||||
def test_empty_target_needs_no_backup_and_reports_missing_sessions(self, tmp_path: Path):
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
(source / "memory").mkdir(parents=True)
|
||||
(source / "memory" / "one.md").write_text("one", encoding="utf-8")
|
||||
target.mkdir()
|
||||
|
||||
result = merge_data_files(str(source), str(target))
|
||||
|
||||
assert result["backupDir"] == ""
|
||||
assert result["sessionsAdded"] == 0
|
||||
assert result["sessionsSkipped"] == 0
|
||||
assert result["sessionsFileFound"] is False
|
||||
|
||||
def test_backup_failure_never_writes_the_destination(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
(source / "memory").mkdir(parents=True)
|
||||
(source / "memory" / "same.md").write_text("new", encoding="utf-8")
|
||||
(source / "temp" / "model_responses").mkdir(parents=True)
|
||||
(source / "temp" / "model_responses" / "added.json").write_text("new", encoding="utf-8")
|
||||
(target / "memory").mkdir(parents=True)
|
||||
(target / "memory" / "same.md").write_text("old", encoding="utf-8")
|
||||
|
||||
def fail_backup(*_args, **_kwargs):
|
||||
raise OSError("backup disk full")
|
||||
|
||||
monkeypatch.setattr(data_backup, "_create_memory_backup", fail_backup)
|
||||
|
||||
with pytest.raises(OSError, match="backup disk full"):
|
||||
merge_data_files(str(source), str(target))
|
||||
|
||||
assert (target / "memory" / "same.md").read_text(encoding="utf-8") == "old"
|
||||
assert not (target / "temp" / "model_responses" / "added.json").exists()
|
||||
|
||||
def test_staging_copy_failure_cleans_up_without_partial_writes(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
responses = source / "temp" / "model_responses"
|
||||
responses.mkdir(parents=True)
|
||||
(responses / "a-ok.json").write_text("ok", encoding="utf-8")
|
||||
(responses / "z-fail.json").write_text("fail", encoding="utf-8")
|
||||
target.mkdir()
|
||||
real_copy2 = data_backup.shutil.copy2
|
||||
|
||||
def fail_second_copy(source_path, destination_path, *args, **kwargs):
|
||||
if Path(source_path).name == "z-fail.json":
|
||||
raise OSError("copy failed")
|
||||
return real_copy2(source_path, destination_path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(data_backup.shutil, "copy2", fail_second_copy)
|
||||
|
||||
with pytest.raises(OSError, match="copy failed"):
|
||||
merge_data_files(str(source), str(target))
|
||||
|
||||
assert not (target / "temp" / "model_responses" / "a-ok.json").exists()
|
||||
assert not list(target.glob(".genericagent-memory-import-*"))
|
||||
|
||||
def test_activation_partial_failure_rolls_memory_and_added_files_back(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
(source / "memory").mkdir(parents=True)
|
||||
(source / "memory" / "same.md").write_text("new", encoding="utf-8")
|
||||
(source / "temp" / "model_responses").mkdir(parents=True)
|
||||
(source / "temp" / "model_responses" / "fail.json").write_text("new", encoding="utf-8")
|
||||
(target / "memory").mkdir(parents=True)
|
||||
(target / "memory" / "same.md").write_text("old", encoding="utf-8")
|
||||
failing_target = target / "temp" / "model_responses" / "fail.json"
|
||||
|
||||
real_install = data_backup._install_file_add_only
|
||||
|
||||
def fail_response_activation(source_path, destination_path):
|
||||
if Path(destination_path) == failing_target:
|
||||
raise OSError("activation failed")
|
||||
return real_install(source_path, destination_path)
|
||||
|
||||
monkeypatch.setattr(
|
||||
data_backup, "_install_file_add_only", fail_response_activation
|
||||
)
|
||||
|
||||
with pytest.raises(OSError, match="activation failed"):
|
||||
merge_data_files(str(source), str(target))
|
||||
|
||||
assert (target / "memory" / "same.md").read_text(encoding="utf-8") == "old"
|
||||
assert not failing_target.exists()
|
||||
backups = list((target / "temp").glob("memory_import_backup_*"))
|
||||
assert len(backups) == 1
|
||||
assert (backups[0] / "memory" / "same.md").read_text(encoding="utf-8") == "old"
|
||||
|
||||
def test_add_only_install_falls_back_on_filesystems_without_hard_links(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
responses = source / "temp" / "model_responses"
|
||||
responses.mkdir(parents=True)
|
||||
(responses / "portable.json").write_text("portable", encoding="utf-8")
|
||||
target.mkdir()
|
||||
|
||||
def unsupported_link(*_args, **_kwargs):
|
||||
raise OSError(data_backup.errno.EOPNOTSUPP, "hard links unsupported")
|
||||
|
||||
monkeypatch.setattr(data_backup.os, "link", unsupported_link)
|
||||
|
||||
result = merge_data_files(str(source), str(target))
|
||||
|
||||
assert result["responsesCopied"] == 1
|
||||
assert (
|
||||
target / "temp" / "model_responses" / "portable.json"
|
||||
).read_text(encoding="utf-8") == "portable"
|
||||
|
||||
def test_concurrent_response_creation_is_never_overwritten(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
(source / "memory").mkdir(parents=True)
|
||||
(source / "memory" / "same.md").write_text("new", encoding="utf-8")
|
||||
responses = source / "temp" / "model_responses"
|
||||
responses.mkdir(parents=True)
|
||||
(responses / "race.json").write_text("import", encoding="utf-8")
|
||||
(target / "memory").mkdir(parents=True)
|
||||
(target / "memory" / "same.md").write_text("old", encoding="utf-8")
|
||||
racing_target = target / "temp" / "model_responses" / "race.json"
|
||||
real_link = data_backup.os.link
|
||||
|
||||
def collide_then_link(source_path, destination_path, **kwargs):
|
||||
destination = Path(destination_path)
|
||||
if destination == racing_target:
|
||||
destination.write_text("concurrent", encoding="utf-8")
|
||||
return real_link(source_path, destination_path, **kwargs)
|
||||
|
||||
monkeypatch.setattr(data_backup.os, "link", collide_then_link)
|
||||
|
||||
with pytest.raises(FileExistsError):
|
||||
merge_data_files(str(source), str(target))
|
||||
|
||||
assert racing_target.read_text(encoding="utf-8") == "concurrent"
|
||||
assert (target / "memory" / "same.md").read_text(encoding="utf-8") == "old"
|
||||
|
||||
def test_memory_backup_refuses_symlinked_temp_directory(self, tmp_path: Path):
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
outside = tmp_path / "outside"
|
||||
(source / "memory").mkdir(parents=True)
|
||||
(source / "memory" / "same.md").write_text("new", encoding="utf-8")
|
||||
(target / "memory").mkdir(parents=True)
|
||||
(target / "memory" / "same.md").write_text("old", encoding="utf-8")
|
||||
outside.mkdir()
|
||||
(target / "temp").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
with pytest.raises(ValueError, match="backup destination"):
|
||||
merge_data_files(str(source), str(target))
|
||||
|
||||
assert (target / "memory" / "same.md").read_text(encoding="utf-8") == "old"
|
||||
assert list(outside.iterdir()) == []
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Contracts for the package-owned bridge/conductor plus external GA_ROOT architecture."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
BRIDGE_SOURCE = (ROOT / "frontends" / "desktop_bridge.py").read_text(encoding="utf-8")
|
||||
CONDUCTOR_SOURCE = (ROOT / "frontends" / "conductor.py").read_text(encoding="utf-8")
|
||||
HUB_SOURCE = (ROOT / "frontends" / "hub.py").read_text(encoding="utf-8")
|
||||
PROBE = ROOT / "frontends" / "ga_contract_probe.py"
|
||||
|
||||
|
||||
def _load_function(source: str, name: str, namespace: dict):
|
||||
tree = ast.parse(source)
|
||||
node = next(item for item in tree.body if isinstance(item, ast.FunctionDef) and item.name == name)
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), "contract.py", "exec"), namespace)
|
||||
return namespace[name]
|
||||
|
||||
|
||||
def test_conductor_prefers_a_valid_external_ga_root(tmp_path, monkeypatch):
|
||||
external = tmp_path / "external"
|
||||
external.mkdir()
|
||||
(external / "agentmain.py").write_text("# core\n", encoding="utf-8")
|
||||
monkeypatch.setenv("GA_ROOT", str(external))
|
||||
resolve = _load_function(CONDUCTOR_SOURCE, "_resolve_ga_root", {"os": os})
|
||||
|
||||
assert resolve() == str(external.resolve())
|
||||
|
||||
|
||||
def test_bridge_discovers_package_conductor_but_external_scheduler(tmp_path):
|
||||
external = tmp_path / "external"
|
||||
(external / "reflect").mkdir(parents=True)
|
||||
(external / "reflect" / "scheduler.py").write_text("# scheduler\n", encoding="utf-8")
|
||||
bundle_frontends = tmp_path / "bundle" / "frontends"
|
||||
bundle_frontends.mkdir(parents=True)
|
||||
bundled_conductor = bundle_frontends / "conductor.py"
|
||||
bundled_conductor.write_text("# conductor\n", encoding="utf-8")
|
||||
discover = _load_function(
|
||||
BRIDGE_SOURCE,
|
||||
"discover_extra_services",
|
||||
{
|
||||
"Path": Path,
|
||||
"List": list,
|
||||
"APP_DIR": bundle_frontends,
|
||||
"sys": sys,
|
||||
"_configured_conductor_port": lambda: 29890,
|
||||
},
|
||||
)
|
||||
|
||||
catalog = {item["id"]: item for item in discover(external)}
|
||||
|
||||
assert catalog["frontends/conductor.py"]["cmd"][1] == str(bundled_conductor)
|
||||
assert catalog["frontends/conductor.py"]["cmd"][-2:] == ["--port", "29890"]
|
||||
assert catalog["frontends/conductor.py"]["port"] == 29890
|
||||
assert catalog["reflect/scheduler.py"]["cmd"][-1] == "reflect/scheduler.py"
|
||||
|
||||
|
||||
def test_service_spawn_injects_effective_ga_root():
|
||||
tree = ast.parse(BRIDGE_SOURCE)
|
||||
service_manager = next(
|
||||
item for item in tree.body if isinstance(item, ast.ClassDef) and item.name == "ServiceManager"
|
||||
)
|
||||
start_service = next(
|
||||
item for item in service_manager.body
|
||||
if isinstance(item, ast.FunctionDef) and item.name == "start_service"
|
||||
)
|
||||
string_constants = {
|
||||
node.value for node in ast.walk(start_service)
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str)
|
||||
}
|
||||
|
||||
assert "GA_ROOT" in string_constants
|
||||
|
||||
|
||||
def test_missing_optional_p2p_dependencies_cannot_abort_hub_setup():
|
||||
tree = ast.parse(HUB_SOURCE)
|
||||
p2p_import = next(
|
||||
node
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.ImportFrom) and node.module == "hub_p2p"
|
||||
)
|
||||
guard = next(
|
||||
node
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Try) and p2p_import in set(ast.walk(node))
|
||||
)
|
||||
|
||||
assert any(
|
||||
handler.type is None
|
||||
or (isinstance(handler.type, ast.Name) and handler.type.id == "Exception")
|
||||
for handler in guard.handlers
|
||||
)
|
||||
|
||||
|
||||
def _run_probe(core: Path) -> tuple[subprocess.CompletedProcess[str], dict]:
|
||||
completed = subprocess.run(
|
||||
[sys.executable, str(PROBE), str(core)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return completed, json.loads(completed.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
def test_compatibility_probe_rejects_a_core_missing_generic_agent(tmp_path):
|
||||
(tmp_path / "agentmain.py").write_text("# no GenericAgent\n", encoding="utf-8")
|
||||
(tmp_path / "llmcore.py").write_text(
|
||||
"def reload_mykeys(): pass\ndef _record_usage(usage, api_mode): pass\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
completed, verdict = _run_probe(tmp_path)
|
||||
|
||||
assert completed.returncode == 1
|
||||
assert verdict == {"ok": False, "missing": ["agentmain.GenericAgent"], "error": ""}
|
||||
|
||||
|
||||
def test_compatibility_probe_accepts_the_desktop_core_contract(tmp_path):
|
||||
(tmp_path / "agentmain.py").write_text(
|
||||
"""class GenericAgent:
|
||||
def run(self): pass
|
||||
def put_task(self, prompt, images=None, source=None): pass
|
||||
def next_llm(self): pass
|
||||
def load_llm_sessions(self): pass
|
||||
def get_llm_name(self, model=None): pass
|
||||
def abort(self): pass
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "llmcore.py").write_text(
|
||||
"def reload_mykeys(): pass\ndef _record_usage(usage, api_mode): pass\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
completed, verdict = _run_probe(tmp_path)
|
||||
|
||||
assert completed.returncode == 0
|
||||
assert verdict == {"ok": True, "missing": [], "error": ""}
|
||||
@@ -0,0 +1,556 @@
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import plistlib
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def load_module(name: str, relative: str):
|
||||
path = ROOT / relative
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
journey = load_module(
|
||||
"real_package_journey",
|
||||
"frontends/desktop/e2e/package/real_package_journey.py",
|
||||
)
|
||||
evidence = load_module(
|
||||
"verify_candidate_evidence",
|
||||
"frontends/desktop/e2e/package/verify_candidate_evidence.py",
|
||||
)
|
||||
|
||||
|
||||
def complete_report(platform: str = "linux"):
|
||||
checks = {name: True for name in evidence.COMMON_CHECKS}
|
||||
isolated_port = 29890
|
||||
ownership = {
|
||||
scenario: owned_conductor(port=isolated_port, pid=44120 + index)
|
||||
for index, scenario in enumerate(sorted(evidence.SUCCESSFUL_APPLICATION_SCENARIOS))
|
||||
}
|
||||
checks["isolatedConductorOwnership"] = ownership
|
||||
checks["ownedProcessStops"] = {
|
||||
scenario: {
|
||||
"app": 45120 + index * 3,
|
||||
"bridge": 45121 + index * 3,
|
||||
"conductor": ownership[scenario]["pid"],
|
||||
}
|
||||
for index, scenario in enumerate(sorted(evidence.SUCCESSFUL_APPLICATION_SCENARIOS))
|
||||
}
|
||||
checks["portRecovery"] = "release-then-production-restart"
|
||||
if platform == "macos":
|
||||
checks["macAppImmutable"] = True
|
||||
bootstrap = {
|
||||
name: {"phase": "failed" if name == "foreign-port" else "ready"}
|
||||
for name in (
|
||||
"first-launch",
|
||||
"warm-restart",
|
||||
"foreign-port",
|
||||
"after-port-release",
|
||||
"relocated",
|
||||
"stale-override",
|
||||
)
|
||||
}
|
||||
return {
|
||||
"expectedCommit": "abc1234",
|
||||
"releaseVersion": "0.2.0",
|
||||
"artifact": {"sha256": "f" * 64},
|
||||
"environment": {"isolatedConductorPort": isolated_port},
|
||||
"success": True,
|
||||
"checks": checks,
|
||||
"bootstrap": bootstrap,
|
||||
"manualChecklist": {"nativeVisuals": "pass"},
|
||||
"screenshots": ["ready.png", "foreign.png"],
|
||||
}
|
||||
|
||||
|
||||
def test_candidate_report_contract_accepts_complete_platform_evidence():
|
||||
assert evidence.assert_report("linux", complete_report(), "abc1234") == []
|
||||
assert evidence.assert_report("macos", complete_report("macos"), "abc1234") == []
|
||||
|
||||
|
||||
def test_candidate_report_contract_rejects_incomplete_manual_and_commit_evidence():
|
||||
report = complete_report()
|
||||
report["expectedCommit"] = "different"
|
||||
report["manualChecklist"]["nativeVisuals"] = "pending"
|
||||
failures = evidence.assert_report("linux", report, "abc1234")
|
||||
assert any("commit" in failure for failure in failures)
|
||||
assert any("manual checklist" in failure for failure in failures)
|
||||
|
||||
|
||||
def test_candidate_report_contract_rejects_partial_or_unowned_conductor_evidence():
|
||||
report = complete_report()
|
||||
report["checks"]["isolatedConductorOwnership"].pop("relocated")
|
||||
report["checks"]["ownedProcessStops"]["warm-restart"].pop("conductor")
|
||||
|
||||
failures = evidence.assert_report("linux", report, "abc1234")
|
||||
|
||||
assert any("ownership does not cover every successful launch" in item for item in failures)
|
||||
assert any("invalid owned process stop evidence for warm-restart" in item for item in failures)
|
||||
|
||||
|
||||
def test_stdlib_fake_model_emits_sse_and_redacts_auth_in_transcript():
|
||||
fake = journey.FakeOpenAI()
|
||||
fake.start()
|
||||
try:
|
||||
request = urllib.request.Request(
|
||||
fake.base_url + "/v1/chat/completions",
|
||||
data=json.dumps({"model": "e2e-model"}).encode(),
|
||||
headers={"Authorization": "Bearer secret", "Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
body = response.read().decode()
|
||||
assert "Harness reply" in body
|
||||
assert "[DONE]" in body
|
||||
assert fake.transcript == [
|
||||
{
|
||||
"path": "/v1/chat/completions",
|
||||
"model": "e2e-model",
|
||||
"authorization": "[redacted]",
|
||||
"at": fake.transcript[0]["at"],
|
||||
}
|
||||
]
|
||||
finally:
|
||||
fake.stop()
|
||||
|
||||
|
||||
def write_info_plist(root: Path, **overrides):
|
||||
contents = root / "Contents"
|
||||
contents.mkdir(parents=True, exist_ok=True)
|
||||
values = {
|
||||
"CFBundleShortVersionString": "0.2.0",
|
||||
"CFBundleVersion": "0.2.0",
|
||||
**overrides,
|
||||
}
|
||||
with (contents / "Info.plist").open("wb") as stream:
|
||||
plistlib.dump(values, stream)
|
||||
|
||||
|
||||
def test_macos_package_version_comes_from_both_native_bundle_keys(tmp_path):
|
||||
write_info_plist(tmp_path)
|
||||
assert journey.read_macos_bundle_versions(tmp_path) == ("0.2.0", "0.2.0")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["CFBundleShortVersionString", "CFBundleVersion"])
|
||||
@pytest.mark.parametrize("value", ["0.2.1", 200])
|
||||
def test_macos_package_version_rejects_wrong_or_non_string_keys(tmp_path, key, value):
|
||||
write_info_plist(tmp_path, **{key: value})
|
||||
with pytest.raises(journey.JourneyFailure, match=key):
|
||||
journey.read_macos_bundle_versions(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["CFBundleShortVersionString", "CFBundleVersion"])
|
||||
def test_macos_package_version_rejects_missing_keys(tmp_path, key):
|
||||
write_info_plist(tmp_path)
|
||||
path = tmp_path / "Contents" / "Info.plist"
|
||||
with path.open("rb") as stream:
|
||||
values = plistlib.load(stream)
|
||||
del values[key]
|
||||
with path.open("wb") as stream:
|
||||
plistlib.dump(values, stream)
|
||||
with pytest.raises(journey.JourneyFailure, match=key):
|
||||
journey.read_macos_bundle_versions(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [b"not a plist", b"", plistlib.dumps([])])
|
||||
def test_macos_package_version_rejects_invalid_or_empty_plist(tmp_path, payload):
|
||||
contents = tmp_path / "Contents"
|
||||
contents.mkdir()
|
||||
(contents / "Info.plist").write_bytes(payload)
|
||||
with pytest.raises(journey.JourneyFailure, match="missing or invalid"):
|
||||
journey.read_macos_bundle_versions(tmp_path)
|
||||
|
||||
|
||||
def test_macos_package_version_rejects_missing_plist(tmp_path):
|
||||
with pytest.raises(journey.JourneyFailure, match="missing or invalid"):
|
||||
journey.read_macos_bundle_versions(tmp_path)
|
||||
|
||||
|
||||
def test_package_shape_rejects_excluded_source_package_json(tmp_path):
|
||||
package_root = tmp_path / "GenericAgent.app"
|
||||
runtime_root = package_root / "Contents" / "Resources" / "runtime"
|
||||
application = package_root / "Contents" / "MacOS" / "GenericAgent"
|
||||
for path in [
|
||||
application,
|
||||
runtime_root / "app" / "agentmain.py",
|
||||
runtime_root / "app" / "frontends" / "desktop_bridge.py",
|
||||
runtime_root / "app" / "frontends" / "desktop" / "static" / "index.html",
|
||||
runtime_root / "python" / "bin" / "python3",
|
||||
runtime_root / ".prepared",
|
||||
runtime_root / "app" / "frontends" / "desktop" / "package.json",
|
||||
]:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("{}", encoding="utf-8")
|
||||
write_info_plist(package_root)
|
||||
|
||||
candidate = object.__new__(journey.Journey)
|
||||
candidate.args = type("Args", (), {"platform": "macos"})()
|
||||
candidate.package_root = package_root
|
||||
candidate.runtime_root = runtime_root
|
||||
candidate.application = application
|
||||
candidate.report = {"checks": {}}
|
||||
|
||||
with pytest.raises(journey.JourneyFailure, match="excluded Desktop source metadata"):
|
||||
candidate.check_package_shape()
|
||||
|
||||
(runtime_root / "app" / "frontends" / "desktop" / "package.json").unlink()
|
||||
candidate.check_package_shape()
|
||||
assert candidate.report["checks"] == {
|
||||
"packagedVersion": "0.2.0",
|
||||
"packagedBundleVersion": "0.2.0",
|
||||
"packageShape": True,
|
||||
}
|
||||
|
||||
|
||||
def chat_snapshot(*, status="idle", unfinished=False):
|
||||
return {
|
||||
"status": status,
|
||||
"hasUnfinishedWork": unfinished,
|
||||
"messages": [{"role": "assistant", "content": "Harness reply"}],
|
||||
"lastError": "model failed" if status == "error" else "",
|
||||
}
|
||||
|
||||
|
||||
def test_package_chat_waits_for_the_live_turn_thread_after_reply_and_idle_status():
|
||||
assert journey.completed_assistant_reply(chat_snapshot(status="running", unfinished=True)) is None
|
||||
assert journey.completed_assistant_reply(chat_snapshot(unfinished=True)) is None
|
||||
assert journey.completed_assistant_reply(chat_snapshot()) == "Harness reply"
|
||||
|
||||
missing_barrier = chat_snapshot()
|
||||
missing_barrier.pop("hasUnfinishedWork")
|
||||
assert journey.completed_assistant_reply(missing_barrier) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["error", "cancelled"])
|
||||
def test_package_chat_fails_immediately_on_terminal_session_state(status):
|
||||
with pytest.raises(journey.JourneyFailure, match=f"terminal state {status}"):
|
||||
journey.completed_assistant_reply(chat_snapshot(status=status))
|
||||
|
||||
|
||||
def valid_import_conflict():
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "managed Desktop services are running",
|
||||
"code": "maintenance_conflict",
|
||||
"runningSessions": [],
|
||||
"runningExtras": ["frontends/conductor.py", "reflect/scheduler.py"],
|
||||
}
|
||||
|
||||
|
||||
def test_package_import_requires_the_exact_running_extras_conflict():
|
||||
assert journey.validate_import_maintenance_conflict(409, valid_import_conflict()) == [
|
||||
"frontends/conductor.py",
|
||||
"reflect/scheduler.py",
|
||||
]
|
||||
|
||||
|
||||
def test_package_request_reads_the_expected_http_error_json(monkeypatch):
|
||||
payload = {**valid_import_conflict(), "runningExtras": ["frontends/conductor.py"]}
|
||||
error = urllib.error.HTTPError(
|
||||
"http://127.0.0.1:14168/memory/import",
|
||||
409,
|
||||
"Conflict",
|
||||
{},
|
||||
io.BytesIO(json.dumps(payload).encode("utf-8")),
|
||||
)
|
||||
|
||||
def reject(*_args, **_kwargs):
|
||||
raise error
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", reject)
|
||||
status, response = journey.request_json_with_status(
|
||||
"POST",
|
||||
"/memory/import",
|
||||
{"sourceDir": "/source"},
|
||||
)
|
||||
|
||||
assert status == 409
|
||||
assert response == payload
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "payload"),
|
||||
[
|
||||
(200, valid_import_conflict()),
|
||||
(409, {**valid_import_conflict(), "ok": True}),
|
||||
(409, {**valid_import_conflict(), "code": "other_conflict"}),
|
||||
(409, {**valid_import_conflict(), "runningSessions": ["sess-live"]}),
|
||||
(409, {**valid_import_conflict(), "runningExtras": []}),
|
||||
(409, {**valid_import_conflict(), "runningExtras": ["reflect/scheduler.py"]}),
|
||||
(409, {**valid_import_conflict(), "runningExtras": ["frontends/conductor.py"] * 2}),
|
||||
],
|
||||
)
|
||||
def test_package_import_rejects_inexact_maintenance_conflicts(status, payload):
|
||||
with pytest.raises(journey.JourneyFailure):
|
||||
journey.validate_import_maintenance_conflict(status, payload)
|
||||
|
||||
|
||||
def stopped_panel(*services):
|
||||
return {"services": list(services)}
|
||||
|
||||
|
||||
def owned_conductor(port=29890, pid=44123):
|
||||
return {
|
||||
"id": "frontends/conductor.py",
|
||||
"status": "running",
|
||||
"running": True,
|
||||
"owned": True,
|
||||
"external": False,
|
||||
"port": port,
|
||||
"pid": pid,
|
||||
}
|
||||
|
||||
|
||||
def test_package_journey_requires_exact_owned_isolated_conductor_state():
|
||||
state = owned_conductor()
|
||||
payload = stopped_panel(state)
|
||||
assert journey.verified_owned_conductor(payload, 29890, True) is state
|
||||
assert journey.verified_owned_conductor(payload, 29890, False) is None
|
||||
|
||||
mutations = [
|
||||
{**state, "status": "error"},
|
||||
{**state, "running": False},
|
||||
{**state, "owned": False},
|
||||
{**state, "external": True},
|
||||
{**state, "port": 8900},
|
||||
{**state, "pid": None},
|
||||
]
|
||||
for mutation in mutations:
|
||||
assert journey.verified_owned_conductor(stopped_panel(mutation), 29890, True) is None
|
||||
assert journey.verified_owned_conductor(stopped_panel(state, state), 29890, True) is None
|
||||
|
||||
|
||||
def test_package_start_injects_only_the_isolated_e2e_conductor_port(tmp_path, monkeypatch):
|
||||
candidate = object.__new__(journey.Journey)
|
||||
candidate.process = None
|
||||
candidate.package_root = tmp_path
|
||||
candidate.application = tmp_path / "GenericAgent"
|
||||
candidate.report_dir = tmp_path / "reports"
|
||||
candidate.conductor_port = 29890
|
||||
candidate.process_log = None
|
||||
candidate.report = {"pids": []}
|
||||
captured = {}
|
||||
|
||||
class FakeProcess:
|
||||
pid = 44120
|
||||
|
||||
def fake_popen(command, **kwargs):
|
||||
captured["command"] = command
|
||||
captured.update(kwargs)
|
||||
return FakeProcess()
|
||||
|
||||
monkeypatch.setattr(journey, "loopback_port_is_free", lambda port: port == 29890)
|
||||
monkeypatch.setattr(journey.subprocess, "Popen", fake_popen)
|
||||
|
||||
candidate.start_application("first-launch")
|
||||
candidate.process_log.close()
|
||||
|
||||
assert captured["env"]["GA_DESKTOP_E2E_CONDUCTOR_PORT"] == "29890"
|
||||
assert captured["env"]["GA_DESKTOP_E2E_REPORT_DIR"].endswith(
|
||||
"bootstrap/first-launch"
|
||||
)
|
||||
assert candidate.report["pids"] == [{"scenario": "first-launch", "app": 44120}]
|
||||
|
||||
|
||||
def test_isolated_conductor_allocator_rejects_active_and_default_product_ports(monkeypatch):
|
||||
candidates = iter([24170, 14168, 8900, 29890])
|
||||
|
||||
class FakeReservation:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def bind(self, _address):
|
||||
return None
|
||||
|
||||
def getsockname(self):
|
||||
return ("127.0.0.1", next(candidates))
|
||||
|
||||
monkeypatch.setattr(journey, "BRIDGE_PORT", 24170)
|
||||
monkeypatch.setattr(journey.socket, "socket", lambda *_args, **_kwargs: FakeReservation())
|
||||
|
||||
assert journey.allocate_isolated_conductor_port() == 29890
|
||||
|
||||
|
||||
def test_every_successful_ready_snapshot_records_its_owned_conductor_pid(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
candidate = object.__new__(journey.Journey)
|
||||
candidate.args = SimpleNamespace(start_timeout=1, expected_commit="abc1234")
|
||||
candidate.report_dir = tmp_path / "report"
|
||||
candidate.external_root = tmp_path / "external"
|
||||
candidate.runtime_root = tmp_path / "package" / "runtime"
|
||||
candidate.conductor_port = 29890
|
||||
candidate.report = {
|
||||
"bootstrap": {},
|
||||
"identities": {},
|
||||
"pids": [{"scenario": "warm-restart", "app": 44120}],
|
||||
"checks": {},
|
||||
}
|
||||
expected_app_dir = candidate.runtime_root / "app" / "frontends"
|
||||
latest = candidate.scenario_dir("warm-restart") / "bootstrap-latest.json"
|
||||
journey.write_json(latest, {"phase": "ready"})
|
||||
|
||||
def fake_request(_method, path, body=None, timeout=5.0):
|
||||
del body, timeout
|
||||
if path == "/services/identity":
|
||||
return {
|
||||
"ga_root": str(candidate.external_root),
|
||||
"app_dir": str(expected_app_dir),
|
||||
"pid": 44121,
|
||||
"build_id": "desktop-abc1234",
|
||||
}
|
||||
if path == "/services/panel":
|
||||
return stopped_panel(owned_conductor(pid=44122))
|
||||
raise AssertionError(path)
|
||||
|
||||
monkeypatch.setattr(journey, "request_json", fake_request)
|
||||
monkeypatch.setattr(
|
||||
journey,
|
||||
"loopback_port_is_free",
|
||||
lambda port: False if port == 29890 else True,
|
||||
)
|
||||
|
||||
candidate.wait_ready("warm-restart", candidate.external_root)
|
||||
|
||||
assert candidate.report["pids"][-1]["bridge"] == 44121
|
||||
assert candidate.report["pids"][-1]["conductor"] == 44122
|
||||
assert candidate.report["checks"]["isolatedConductorOwnership"] == {
|
||||
"warm-restart": owned_conductor(pid=44122)
|
||||
}
|
||||
|
||||
|
||||
def test_package_import_waits_until_every_reported_extra_is_offline():
|
||||
ids = ["frontends/conductor.py", "reflect/scheduler.py"]
|
||||
complete = stopped_panel(
|
||||
{"id": "frontends/conductor.py", "running": False, "status": "offline"},
|
||||
{"id": "reflect/scheduler.py", "running": False, "status": "offline"},
|
||||
)
|
||||
assert journey.verified_stopped_extras_panel(complete, ids) is complete
|
||||
assert journey.verified_stopped_extras_panel(
|
||||
stopped_panel({"id": ids[0], "running": False, "status": "offline"}),
|
||||
ids,
|
||||
) is None
|
||||
assert journey.verified_stopped_extras_panel(
|
||||
stopped_panel(
|
||||
{"id": ids[0], "running": False, "status": "offline"},
|
||||
{"id": ids[1], "running": True, "status": "running"},
|
||||
),
|
||||
ids,
|
||||
) is None
|
||||
assert journey.verified_stopped_extras_panel(
|
||||
stopped_panel(
|
||||
{"id": ids[0], "running": False, "status": "offline"},
|
||||
{"id": ids[1], "running": False, "status": "error"},
|
||||
),
|
||||
ids,
|
||||
) is None
|
||||
|
||||
|
||||
def test_real_package_memory_import_stops_reported_extras_before_one_successful_import(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
candidate = object.__new__(journey.Journey)
|
||||
candidate.external_root = tmp_path / "external"
|
||||
candidate.work_dir = tmp_path / "work"
|
||||
candidate.fake = SimpleNamespace(transcript=[{"model": "e2e-model"}])
|
||||
candidate.report = {"checks": {}}
|
||||
sid = "sess-package-test"
|
||||
session_file = candidate.external_root / "temp" / "desktop_sessions" / f"{sid}.json"
|
||||
imported_sid = "sess-p2-package-imported"
|
||||
stopped = False
|
||||
calls = []
|
||||
|
||||
def fake_request(method, path, body=None, timeout=5.0):
|
||||
nonlocal stopped
|
||||
calls.append((method, path))
|
||||
if path == "/session/new":
|
||||
session_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
session_file.write_text('{"id":"sess-package-test"}\n', encoding="utf-8")
|
||||
return {"sessionId": sid}
|
||||
if path == "/upload":
|
||||
upload = candidate.external_root / "temp" / "desktop_uploads" / "p2.png"
|
||||
upload.parent.mkdir(parents=True, exist_ok=True)
|
||||
upload.write_bytes(b"png")
|
||||
return {"ok": True, "path": str(upload)}
|
||||
if path.endswith("/prompt"):
|
||||
return {"ok": True}
|
||||
if path.endswith("/messages?limit=20"):
|
||||
return chat_snapshot()
|
||||
if path == "/services/stop-extras":
|
||||
stopped = True
|
||||
return {"ok": True}
|
||||
if path == "/services/panel":
|
||||
assert stopped is True
|
||||
return stopped_panel(
|
||||
{"id": "frontends/conductor.py", "running": False, "status": "offline"}
|
||||
)
|
||||
if path == "/memory/import":
|
||||
assert stopped is True
|
||||
source = Path(body["sourceDir"])
|
||||
memory = candidate.external_root / "memory" / "p2-package-memory.txt"
|
||||
responses = candidate.external_root / "temp" / "model_responses"
|
||||
backup = candidate.external_root / "temp" / "memory_import_backup_test"
|
||||
(backup / "memory").mkdir(parents=True)
|
||||
(backup / "memory" / memory.name).write_bytes(memory.read_bytes())
|
||||
memory.write_bytes((source / "memory" / memory.name).read_bytes())
|
||||
(responses / "new.json").write_bytes(
|
||||
(source / "temp" / "model_responses" / "new.json").read_bytes()
|
||||
)
|
||||
imported = candidate.external_root / "temp" / "desktop_sessions" / f"{imported_sid}.json"
|
||||
imported.write_bytes(
|
||||
(source / "temp" / "desktop_sessions" / imported.name).read_bytes()
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"memoryCopied": 1,
|
||||
"responsesCopied": 1,
|
||||
"responsesSkipped": 1,
|
||||
"sessionsAdded": 1,
|
||||
"backupDir": str(backup),
|
||||
}
|
||||
raise AssertionError(f"unexpected request: {method} {path}")
|
||||
|
||||
def fake_conflict(method, path, body=None, timeout=5.0):
|
||||
calls.append((method, f"{path}:expected-conflict"))
|
||||
memory = candidate.external_root / "memory" / "p2-package-memory.txt"
|
||||
responses = candidate.external_root / "temp" / "model_responses"
|
||||
assert memory.read_text(encoding="utf-8") == "before\n"
|
||||
assert (responses / "shared.json").read_text(encoding="utf-8") == "destination\n"
|
||||
assert not (responses / "new.json").exists()
|
||||
assert session_file.read_text(encoding="utf-8") == '{"id":"sess-package-test"}\n'
|
||||
return 409, {**valid_import_conflict(), "runningExtras": ["frontends/conductor.py"]}
|
||||
|
||||
monkeypatch.setattr(journey, "request_json", fake_request)
|
||||
monkeypatch.setattr(journey, "request_json_with_status", fake_conflict)
|
||||
|
||||
candidate.run_chat_upload_memory()
|
||||
|
||||
assert calls == [
|
||||
("POST", "/session/new"),
|
||||
("POST", "/upload"),
|
||||
("POST", f"/session/{sid}/prompt"),
|
||||
("GET", f"/session/{sid}/messages?limit=20"),
|
||||
("POST", "/memory/import:expected-conflict"),
|
||||
("POST", "/services/stop-extras"),
|
||||
("GET", "/services/panel"),
|
||||
("POST", "/memory/import"),
|
||||
]
|
||||
gate = candidate.report["checks"]["memoryImportMaintenanceGate"]
|
||||
assert gate["runningSessions"] == []
|
||||
assert gate["runningExtras"] == ["frontends/conductor.py"]
|
||||
assert candidate.report["checks"]["memoryImport"]["ok"] is True
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Security contracts for the packaged Tauri shell."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
TAURI_ROOT = ROOT / "frontends" / "desktop" / "src-tauri"
|
||||
|
||||
|
||||
def _json(path: Path) -> dict:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_production_csp_is_local_and_keeps_required_tauri_ipc_and_services():
|
||||
config = _json(TAURI_ROOT / "tauri.conf.json")
|
||||
security = config["app"]["security"]
|
||||
csp = security["csp"]
|
||||
|
||||
assert isinstance(csp, dict)
|
||||
assert csp["object-src"] == "'none'"
|
||||
assert csp["base-uri"] == "'self'"
|
||||
assert csp["form-action"] == "'none'"
|
||||
assert csp["frame-src"] == "'none'"
|
||||
assert csp["frame-ancestors"] == "'none'"
|
||||
assert "'unsafe-inline'" not in csp["script-src"]
|
||||
assert "'unsafe-eval'" not in csp["script-src"]
|
||||
|
||||
connect_sources = set(csp["connect-src"].split())
|
||||
assert {
|
||||
"'self'",
|
||||
"ipc:",
|
||||
"http://ipc.localhost",
|
||||
"http://127.0.0.1:14168",
|
||||
"ws://127.0.0.1:14168",
|
||||
"http://127.0.0.1:8900",
|
||||
"ws://127.0.0.1:8900",
|
||||
} <= connect_sources
|
||||
assert not any("*" in source for source in connect_sources)
|
||||
assert not any(source.startswith("https://") for source in connect_sources)
|
||||
|
||||
|
||||
def test_global_tauri_remains_only_because_compiled_compatibility_page_uses_it():
|
||||
config = _json(TAURI_ROOT / "tauri.conf.json")
|
||||
fallback = (ROOT / "frontends" / "desktop" / "dist" / "fallback.html").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert config["app"]["withGlobalTauri"] is True
|
||||
assert "window.__TAURI__" in fallback
|
||||
|
||||
|
||||
def test_main_and_setup_capabilities_are_window_scoped_without_remote_ipc_access():
|
||||
config = _json(TAURI_ROOT / "tauri.conf.json")
|
||||
main = _json(TAURI_ROOT / "capabilities" / "default.json")
|
||||
setup = _json(TAURI_ROOT / "capabilities" / "setup.json")
|
||||
|
||||
assert config["app"]["security"]["capabilities"] == ["default", "setup"]
|
||||
assert main["windows"] == ["main"]
|
||||
assert setup["windows"] == ["setup"]
|
||||
assert "remote" not in main
|
||||
assert "remote" not in setup
|
||||
|
||||
main_permissions = set(main["permissions"])
|
||||
setup_permissions = set(setup["permissions"])
|
||||
assert "core:default" not in main_permissions
|
||||
assert "core:window:default" not in main_permissions
|
||||
assert "core:webview:default" not in main_permissions
|
||||
assert "opener:default" not in main_permissions
|
||||
assert {
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-start-dragging",
|
||||
"opener:allow-open-url",
|
||||
"opener:allow-default-urls",
|
||||
} <= main_permissions
|
||||
|
||||
assert {
|
||||
"allow-start-bridge-with-config",
|
||||
"allow-retry-bootstrap",
|
||||
"allow-get-bootstrap-snapshot",
|
||||
"allow-get-config",
|
||||
"allow-discover-python-for-project",
|
||||
"allow-pick-directory",
|
||||
"allow-pick-python-interpreter",
|
||||
} <= setup_permissions
|
||||
assert not setup_permissions.intersection(
|
||||
{
|
||||
"allow-export-mykey",
|
||||
"allow-pick-data-export-path",
|
||||
"allow-reveal-in-file-manager",
|
||||
"allow-set-ga-source",
|
||||
"allow-clear-ga-source",
|
||||
"opener:allow-open-url",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_e2e_config_only_adds_test_driver_and_dynamic_loopback_connectivity():
|
||||
e2e = _json(TAURI_ROOT / "tauri.e2e.conf.json")
|
||||
security = e2e["app"]["security"]
|
||||
capabilities = security["capabilities"]
|
||||
assert capabilities[:2] == ["default", "setup"]
|
||||
capability = capabilities[2]
|
||||
|
||||
assert capability["identifier"] == "e2e"
|
||||
assert capability["permissions"] == ["wdio:default"]
|
||||
|
||||
assert set(security["csp"]) == {"connect-src"}
|
||||
connect_sources = set(security["csp"]["connect-src"].split())
|
||||
assert {
|
||||
"'self'",
|
||||
"ipc:",
|
||||
"http://ipc.localhost",
|
||||
"http://127.0.0.1:*",
|
||||
"ws://127.0.0.1:*",
|
||||
} <= connect_sources
|
||||
assert {source for source in connect_sources if "*" in source} == {
|
||||
"http://127.0.0.1:*",
|
||||
"ws://127.0.0.1:*",
|
||||
}
|
||||
assert not any(source.startswith("https://") for source in connect_sources)
|
||||
Reference in new issue
Block a user