Compare commits

..
88 changed files with 320 additions and 4299 deletions
-17
View File
@@ -1,17 +0,0 @@
"""Reject skipped/empty native CI runs without conflating driver child counts."""
import argparse
from pathlib import Path
import xml.etree.ElementTree as ET
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('report', type=Path)
parser.add_argument('--minimum', type=int, required=True)
args = parser.parse_args()
root = ET.parse(args.report).getroot()
cases = list(root.iter('testcase'))
if len(cases) < args.minimum:
raise SystemExit(f'Incomplete execution: {len(cases)} cases, expected at least {args.minimum}')
for case in cases:
if any(case.find(tag) is not None for tag in ('skipped', 'failure', 'error')):
raise SystemExit(f'Case did not pass: {case.get("classname")}::{case.get("name")}')
print(f'Verified {len(cases)} executed cases; no skipped, failed or errored cases')
-46
View File
@@ -1,46 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { parse } from 'acorn';
const root = fileURLToPath(new URL('../../', import.meta.url));
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'tests/native-ci-manifest.json'), 'utf8'));
function files(directory) {
return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
const target = path.join(directory, entry.name);
return entry.isDirectory() ? files(target) : /\.(?:mjs|cjs|js)$/.test(entry.name) ? [target] : [];
});
}
function importsNativeTest(node) {
if (!node || typeof node !== 'object') return false;
if (node.type === 'ImportDeclaration' && node.source.value === 'node:test') return true;
if (node.type === 'CallExpression' && node.callee.name === 'require' && node.arguments[0]?.value === 'node:test') return true;
return Object.values(node).some(value => Array.isArray(value) ? value.some(importsNativeTest) : importsNativeTest(value));
}
const discovered = files(path.join(root, 'tests')).filter(file => {
const source = fs.readFileSync(file, 'utf8');
return source.includes('node:test') && importsNativeTest(parse(source, { ecmaVersion: 'latest', sourceType: 'module' }));
}).map(file => path.relative(root, file).split(path.sep).join('/')).sort();
const assigned = Object.values(manifest).flatMap(group => Array.isArray(group) ? group : Object.keys(group));
if (new Set(assigned).size !== assigned.length || JSON.stringify([...assigned].sort()) !== JSON.stringify(discovered)) {
throw new Error('Native test coverage must be exhaustive and disjoint; update tests/native-ci-manifest.json for added, removed or duplicated files');
}
for (const wrappers of Object.values(manifest['python-ci'])) {
for (const wrapper of wrappers) if (!fs.existsSync(path.join(root, wrapper))) throw new Error(`Missing pytest wrapper: ${wrapper}`);
}
if (process.argv.includes('--list')) {
console.log(JSON.stringify({ discovered, runners: manifest }, null, 2));
} else {
const reports = path.join(root, 'test-results/native-node');
fs.mkdirSync(reports, { recursive: true });
const result = spawnSync(process.execPath, ['--test', '--test-concurrency=1',
'--test-reporter=spec', '--test-reporter-destination=stdout',
'--test-reporter=junit', `--test-reporter-destination=${path.join(reports, 'junit.xml')}`,
...manifest['native-ci']], {
cwd: root, stdio: 'inherit',
env: { ...process.env, ENDPOINT_URL: 'http://127.0.0.1:1' },
});
if (result.error) throw result.error;
process.exitCode = result.status ?? 1;
}
@@ -1,465 +0,0 @@
#!/usr/bin/env python3
"""Fail-closed independent verification and sanitization gate for test diagnostics.
Runs after test suite completion or abnormal runner termination (e.g. failures, timeouts, crashes).
Ensures that no unsanitized traces, raw credentials, temporary archives, or unauthorized
diagnostics can ever be published as CI artifacts, while preserving safe JUnit XML reporting.
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import re
import shutil
import sys
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path
# Add repository root to Python path to import sanitizer
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
try:
from scripts.sanitize_trace_artifacts import sanitize_trace_archive
except ImportError:
# If import fails, define fail-closed fallback
def sanitize_trace_archive(zp: str) -> bool:
p = Path(zp)
if p.exists():
try:
p.unlink(missing_ok=True)
except OSError:
pass
return False
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("verify_sanitized_artifacts")
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
ALLOWED_JUNIT_ROOT_TAGS = frozenset({"testsuites", "testsuite"})
ALLOWED_PUBLISHED_JUNIT_TAGS = frozenset({"testsuites", "testsuite", "testcase", "failure", "error", "skipped"})
DISALLOWED_JUNIT_TAGS = frozenset({"properties", "property", "system-out", "system-err"})
ALLOWED_ATTRIBUTES_BY_TAG = {
"testsuites": frozenset({"name", "tests", "failures", "errors", "skipped", "time", "id", "disabled"}),
"testsuite": frozenset({
"name", "tests", "failures", "errors", "skipped", "time", "timestamp", "hostname",
"id", "package", "disabled", "file"
}),
"testcase": frozenset({"name", "classname", "time", "file", "line", "assertions", "status"}),
"failure": frozenset({"message", "type"}),
"error": frozenset({"message", "type"}),
"skipped": frozenset({"message", "type"}),
}
SAFE_SKIPPED_STATUSES = frozenset({"skipped", "todo", "ignored", "disabled", "pending", "omitted", "test skipped"})
SENSITIVE_KEY_PATTERN = re.compile(
r"(?i)(password|passwd|secret|token|api_key|apikey|access_token|refresh_token|"
r"private_key|auth_token|client_secret|session|session_id|sid|csrf|xsrf|jwt|credential|authorization)"
)
SECRET_REDACTION_PATTERNS = [
# 1. Authorization headers (Bearer / Basic)
re.compile(r"(?i)\b(bearer|basic)\s+([^\s,;\'\"<>]+)"),
# 2. Sensitive key-value pairs (key=value or key: value)
re.compile(
r"(?i)\b([a-z0-9_.-]*(?:password|passwd|secret|token|api_key|apikey|access_token|refresh_token|"
r"auth_token|client_secret|session|session_id|sid|csrf|xsrf|jwt|credential|authorization)[a-z0-9_.-]*)"
r"\s*([:=])\s*([^\s,;\'\"<>]+)"
),
# 3. URL credentials (scheme://user:pass@host)
re.compile(r"([a-zA-Z][a-zA-Z0-9+.-]*://[^:\s]+):([^@\s]+)@"),
# 4. JSON-formatted key-value pairs
re.compile(
r'(?i)("?(?:[a-z0-9_.-]*(?:password|passwd|secret|token|key|auth)[a-z0-9_.-]*)"?\s*:\s*")([^"]+)(")'
),
# 5. Synthetic secret keywords or tokens
re.compile(r"(?i)\b[a-z0-9_.-]*synthetic[-_]?[a-z0-9_.-]*\b"),
]
def safe_remove(filepath: str | Path) -> bool:
"""Safely remove a file or directory fail-closed.
Returns True if the target does not exist or was successfully removed.
Returns False if removal failed and the target still exists on disk.
"""
p = Path(filepath)
if not p.exists():
return True
try:
if p.is_dir():
shutil.rmtree(p, ignore_errors=False)
else:
p.unlink(missing_ok=True)
except OSError as exc:
logger.error("Failed to delete unauthorized file %s: %s", filepath, exc)
return False
if p.exists():
logger.error("Target %s still exists on disk after deletion attempt", filepath)
return False
return True
def sanitize_text_content(text: str) -> tuple[str, bool]:
"""Scrub sensitive credentials, tokens, and synthetic secrets from text."""
if not text or not isinstance(text, str):
return text, False
modified = False
out = text
# 1. Replace Bearer / Basic tokens
def _sub_auth(m: re.Match) -> str:
nonlocal modified
modified = True
return f"{m.group(1)} [REDACTED]"
out = SECRET_REDACTION_PATTERNS[0].sub(_sub_auth, out)
# 2. Replace key-value pairs
def _sub_kv(m: re.Match) -> str:
nonlocal modified
modified = True
return f"{m.group(1)}{m.group(2)}[REDACTED]"
out = SECRET_REDACTION_PATTERNS[1].sub(_sub_kv, out)
# 3. Replace URL credentials
def _sub_url(m: re.Match) -> str:
nonlocal modified
modified = True
return f"{m.group(1)}:[REDACTED]@"
out = SECRET_REDACTION_PATTERNS[2].sub(_sub_url, out)
# 4. Replace JSON key-value secrets
def _sub_json(m: re.Match) -> str:
nonlocal modified
modified = True
return f"{m.group(1)}[REDACTED]{m.group(3)}"
out = SECRET_REDACTION_PATTERNS[3].sub(_sub_json, out)
# 5. Replace synthetic secret markers
def _sub_synthetic(m: re.Match) -> str:
nonlocal modified
modified = True
return "[REDACTED]"
out = SECRET_REDACTION_PATTERNS[4].sub(_sub_synthetic, out)
return out, modified
def prune_and_sanitize_junit_element(elem: ET.Element) -> bool:
"""Recursively enforce restrictive published-JUnit representation.
Removes properties, property entries, system-out, system-err, and any
unnecessary free-form content. Preserves test identities, counts, outcomes,
and safe failure classifications.
Returns True if any modifications were made.
"""
modified = False
# 1. Prune child elements not permitted in restrictive published representation
for child in list(elem):
tag_lower = child.tag.lower()
if tag_lower in DISALLOWED_JUNIT_TAGS or tag_lower not in ALLOWED_PUBLISHED_JUNIT_TAGS:
elem.remove(child)
modified = True
else:
if prune_and_sanitize_junit_element(child):
modified = True
tag_lower = elem.tag.lower()
# 2. Prune attributes not permitted in restrictive published representation
allowed_attrs = ALLOWED_ATTRIBUTES_BY_TAG.get(tag_lower, frozenset())
for attr_name in list(elem.attrib.keys()):
attr_lower = attr_name.lower()
if attr_lower not in allowed_attrs:
del elem.attrib[attr_name]
modified = True
elif tag_lower == "skipped":
val = elem.attrib[attr_name].strip()
if val.lower() in SAFE_SKIPPED_STATUSES:
if elem.attrib[attr_name] != val.lower():
elem.attrib[attr_name] = val.lower()
modified = True
else:
elem.attrib[attr_name] = "skipped"
modified = True
else:
val = elem.attrib[attr_name]
if isinstance(val, str):
new_val, changed = sanitize_text_content(val)
if changed:
elem.attrib[attr_name] = new_val
modified = True
# 3. Handle text content: structural and skipped elements do not carry free-form text
if tag_lower in ("testsuites", "testsuite", "testcase", "skipped"):
if elem.text:
elem.text = None
modified = True
elif tag_lower in ("failure", "error"):
if elem.text:
new_text, changed = sanitize_text_content(elem.text)
if changed:
elem.text = new_text
modified = True
# 4. Clean up tails: strip any non-whitespace characters from tails to prevent leaks
if elem.tail and elem.tail.strip():
cleaned_tail = re.sub(r"\S+", "", elem.tail)
if cleaned_tail != elem.tail:
elem.tail = cleaned_tail
modified = True
return modified
def sanitize_and_verify_junit_xml(filepath: str | Path) -> bool:
"""Verify that an XML file is an authentic JUnit report and enforce published representation.
Returns True if the XML file is a valid recognized JUnit report and was safely sanitized.
Returns False if the file is invalid, unrecognized, corrupted, or failed sanitization.
"""
p = Path(filepath)
name_lower = p.name.lower()
# 1. Filename restriction: must match standard JUnit report naming
if not (name_lower == "junit.xml" or name_lower == "browser-junit.xml" or name_lower.endswith("-junit.xml") or name_lower.endswith(".junit.xml")):
logger.warning("Unrecognized XML report filename %s; not an authorized JUnit report", filepath)
return False
# 2. Parse XML
try:
tree = ET.parse(p)
except Exception as exc:
logger.warning("Corrupted XML report %s: %s", filepath, exc)
return False
root = tree.getroot()
# 3. Root tag restriction
if root.tag.lower() not in ALLOWED_JUNIT_ROOT_TAGS:
logger.warning("XML report %s has invalid root element <%s>; not a JUnit report", filepath, root.tag)
return False
# 4. Enforce restrictive published representation on root and all descendants
prune_and_sanitize_junit_element(root)
# 5. Normalize and rewrite sanitized report
try:
tree.write(str(p), encoding="utf-8", xml_declaration=True)
# Verify rewritten XML parses cleanly
ET.parse(p)
except Exception as exc:
logger.error("Failed to write sanitized XML report %s: %s", filepath, exc)
return False
return True
def is_valid_disposable_ci_fixture(target_dir: str | Path | None = None) -> bool:
"""Verify that execution ran inside an authorized disposable synthetic fixture."""
is_ci = bool(os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS"))
if not is_ci:
# Local runs require explicit trace opt-in
trace_opt_in = os.environ.get("PLAYWRIGHT_ENABLE_TRACE", "").lower() in ("1", "true")
return trace_opt_in
if os.environ.get("ODYSSEUS_E2E_DISPOSABLE_ARTIFACTS") != "1":
return False
if os.environ.get("PHOTO_EDITOR_E2E_AUTH") == "0":
return False
allowed_tmp = [os.path.abspath(p) for p in (tempfile.gettempdir(), "/tmp", "/var/tmp")]
# Check 1: Verify fixture ownership via PHOTO_EDITOR_E2E_DATA_DIR
data_dir = os.environ.get("PHOTO_EDITOR_E2E_DATA_DIR")
if data_dir:
resolved = os.path.abspath(data_dir)
base_name = os.path.basename(resolved)
is_tmp = any(resolved.startswith(b + os.sep) or resolved == b for b in allowed_tmp)
if base_name.startswith("odysseus-photo-editor-e2e-") and is_tmp:
expected_db = os.path.join(resolved, "app.db")
db_path = os.environ.get("PHOTO_EDITOR_E2E_DB_PATH")
if not db_path or db_path == expected_db:
marker_path = os.path.join(resolved, ".disposable-ci-fixture.json")
if os.path.exists(marker_path):
try:
with open(marker_path, "r", encoding="utf-8") as f:
fixture = json.load(f)
if (
fixture.get("synthetic") is True
and fixture.get("authenticated") is True
and fixture.get("database") == expected_db
):
return True
except Exception:
pass
# Check 2: Verify fixture proof in target_dir if provided
if target_dir:
target_marker = os.path.join(str(target_dir), ".disposable-ci-fixture.json")
if os.path.exists(target_marker):
try:
with open(target_marker, "r", encoding="utf-8") as f:
fixture = json.load(f)
db_in_fixture = fixture.get("database", "")
resolved_db = os.path.abspath(db_in_fixture)
db_dir = os.path.dirname(resolved_db)
db_base = os.path.basename(db_dir)
is_tmp = any(db_dir.startswith(b + os.sep) or db_dir == b for b in allowed_tmp)
if (
fixture.get("synthetic") is True
and fixture.get("authenticated") is True
and db_base.startswith("odysseus-photo-editor-e2e-")
and is_tmp
and os.path.basename(resolved_db) == "app.db"
):
return True
except Exception:
pass
return False
def verify_and_sanitize_artifacts(target_dir: str) -> bool:
"""Independently verify and sanitize diagnostics fail-closed, preserving safe JUnit XML.
Returns True if verification succeeded and artifacts are safe for publication.
Returns False if verification failed or deletion could not be confirmed.
"""
if not os.path.exists(target_dir):
logger.info("Target directory %s does not exist; nothing to sanitize.", target_dir)
return True
disposable_ci = is_valid_disposable_ci_fixture(target_dir)
preserved_xmls = 0
sanitized_archives = 0
purged_files = 0
overall_success = True
try:
for root, dirs, files in os.walk(target_dir, topdown=False):
for filename in files:
filepath = os.path.join(root, filename)
name_lower = filename.lower()
# Cleanup disposable fixture marker so it is not published
if filename == ".disposable-ci-fixture.json":
if not safe_remove(filepath):
overall_success = False
continue
# 1. XML reports: verify JUnit structure and sanitize credentials
if name_lower.endswith(".xml"):
if sanitize_and_verify_junit_xml(filepath):
preserved_xmls += 1
continue
else:
logger.warning("Rejecting invalid/unrecognized XML report %s; purging fail-closed", filepath)
if not safe_remove(filepath):
overall_success = False
purged_files += 1
continue
# 2. Check if diagnostics are authorized (disposable synthetic fixture)
if not disposable_ci:
logger.info("Purging diagnostic artifact %s (disposable CI fixture not verified)", filepath)
if not safe_remove(filepath):
overall_success = False
purged_files += 1
continue
# 3. Disposable CI is authorized: enforce strict sanitization
if name_lower.endswith(".zip"):
ok = sanitize_trace_archive(filepath)
if ok and os.path.exists(filepath):
sanitized_archives += 1
else:
logger.warning("Trace archive %s failed sanitization; purging fail-closed", filepath)
if not safe_remove(filepath):
overall_success = False
purged_files += 1
elif name_lower.endswith(".png"):
# Validate PNG magic bytes to ensure not a secret payload disguised as an image
is_png = False
try:
with open(filepath, "rb") as pf:
header = pf.read(8)
if header == PNG_MAGIC:
is_png = True
except Exception:
is_png = False
if is_png:
continue
logger.warning("Invalid or suspicious screenshot %s; purging fail-closed", filepath)
if not safe_remove(filepath):
overall_success = False
purged_files += 1
else:
# Purge any .tmp, .har, .db, .sqlite, .log or unrecognized files
logger.info("Purging unauthorized diagnostic file %s", filepath)
if not safe_remove(filepath):
overall_success = False
purged_files += 1
# Remove empty subdirectories
for d in dirs:
dirpath = os.path.join(root, d)
try:
if not os.listdir(dirpath):
os.rmdir(dirpath)
except OSError:
pass
if not overall_success:
logger.error("Verification failed: one or more unauthorized files could not be purged.")
return False
logger.info(
"Artifact verification complete for %s: preserved %d XML reports, sanitized %d archives, purged %d files.",
target_dir,
preserved_xmls,
sanitized_archives,
purged_files,
)
return True
except Exception as exc:
logger.error("Unexpected error during artifact verification: %s; purging all non-XML files fail-closed", exc)
try:
for p in Path(target_dir).rglob("*"):
if p.is_file() and not p.name.lower().endswith(".xml"):
safe_remove(p)
except Exception:
pass
return False
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("target_dir", nargs="?", default="test-results", help="Directory containing test results")
args = parser.parse_args()
ok = verify_and_sanitize_artifacts(args.target_dir)
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()
-129
View File
@@ -1,129 +0,0 @@
name: Native test suites
on:
pull_request:
push:
branches: [main, dev]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: native-tests-${{ github.ref }}
cancel-in-progress: true
jobs:
native-node:
name: Native Node regressions
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.11'
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22.23.4'
cache: npm
- run: npm ci --ignore-scripts --no-audit --no-fund
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: ms-playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- run: npx playwright install --with-deps chromium
- name: Validate exhaustive disjoint runner mapping and execute omitted suites
run: |
node .github/scripts/run_native_tests.mjs
python .github/scripts/check_test_execution.py test-results/native-node/junit.xml --minimum 43
- name: Verify and sanitize native test results fail-closed
id: verify-native-results
if: always()
run: |
python .github/scripts/verify_sanitized_artifacts.py test-results/native-node/
- name: Save native test identities and results
if: always() && steps.verify-native-results.outcome == 'success'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: native-node-results
path: test-results/native-node/
retention-days: 7
if-no-files-found: warn
browser:
name: Chromium application regressions
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
ODYSSEUS_E2E_DISPOSABLE_ARTIFACTS: '1'
ODYSSEUS_TEST_PYTHON: python
CHROMADB_HOST: 127.0.0.1
CHROMADB_PORT: '1'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.11'
- name: Install FFmpeg and establish system trust
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends ffmpeg ca-certificates
python scripts/check_ca_trust.py --system
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
version: '0.13.5'
enable-cache: true
cache-dependency-glob: requirements.txt
prune-cache: false
- run: |
uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
python scripts/check_ca_trust.py --python
uv pip install --system -r requirements.txt
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22.23.4'
cache: npm
- run: npm ci --ignore-scripts --no-audit --no-fund
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: ms-playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- run: npx playwright install --with-deps chromium
- name: Run complete Chromium suite against a fresh authenticated app
env:
PLAYWRIGHT_JUNIT_OUTPUT_FILE: test-results/browser-junit.xml
run: |
npm run test:photo-editor -- --reporter=line,junit
python .github/scripts/check_test_execution.py test-results/browser-junit.xml --minimum 137
- name: Verify and sanitize failure diagnostics fail-closed
id: verify-browser-results
if: always()
run: |
python .github/scripts/verify_sanitized_artifacts.py test-results/
- name: Save per-test results and sanitized failure diagnostics
if: always() && steps.verify-browser-results.outcome == 'success'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: chromium-regression-results
path: test-results/
retention-days: 7
if-no-files-found: warn
authoritative-native-suites:
name: Authoritative native suites
if: always()
needs: [native-node, browser]
runs-on: ubuntu-24.04
steps:
- name: Require both complete suites to succeed
env:
NODE_RESULT: ${{ needs.native-node.result }}
BROWSER_RESULT: ${{ needs.browser.result }}
run: |
test "$NODE_RESULT" = success
test "$BROWSER_RESULT" = success
-46
View File
@@ -1,46 +0,0 @@
# Native test merge gate
`Native test suites` runs on pull requests, dev/main pushes and manual dispatch.
`Authoritative native suites` fails unless both the native Node and complete
Chromium jobs succeed; it has no continue-on-error, retries or path bypass.
Repository administrators must require this check alongside the four pytest
checks in branch protection. The audited public dev branch had no protection or
ruleset, so merely adding workflow jobs cannot prevent a manual merge.
`tests/native-ci-manifest.json` exhaustively partitions all native Node files:
16 run in the native job,14 through existing pytest wrappers, one through the
existing frontend architecture CI step, and the formerly live email-reply file
through an explicitly identified Playwright driver. The manifest validator fails
for omitted/duplicate/unreachable entries. Node emits individual JUnit identities
and propagates its failure exit. No native file is newly run in each pytest shard.
The browser job uses the existing one-worker Chromium config and all 136 Photo
Editor cases (including cursor and brush regressions), plus one driver for the native
email-reply contract (137 cases total). Its ephemeral account,
SQLite DB and server are created by the config/setup; reuseExistingServer remains
false. NPX startup is offline/cache-only in a private cache; Notes/editor behavior
and Python MCP services remain real. No developer app or external account is used.
Default CI trace suppression remains intact. Only this job's explicit opt-in plus
a newly generated authenticated synthetic fixture marker permits failure traces.
The existing structured sanitizer runs before artifacts are uploaded; invalid
ownership, failed sanitization and missing tooling purge sensitive artifacts.
Unsupported or malformed sensitive records cause the affected archive to be removed
fail-closed. An independent fail-closed verification step (.github/scripts/verify_sanitized_artifacts.py)
runs after test suite execution or abnormal runner termination to prevent publishing
unsanitized diagnostics while preserving safe JUnit reporting. The verifier returns a nonzero
exit code upon any inspection or deletion failure, publishable XML is restricted to recognized
JUnit reports with sensitive tokens redacted, and workflow upload steps execute only when
independent verification succeeds (steps.<id>.outcome == 'success').
Never upload the fixture directory, database or browser storage state.
The stroke budget now excludes measured synthetic event-delivery pacing and also
bounds actual Chromium main-thread work at the original5s. All240 samples and
pixel/thumbnail/undo/redo assertions remain. Per-case timing attachments expose
both clocks rather than treating driver frame cadence as application work.
Three Bombadil properties remain opt-in: they require a Bombadil/browser scenario
and bounded fuzz campaign; no authoritative execution is claimed. The two model
smoke scripts require supplied model endpoints/models and remain explicit live
operations. No credentials or live-service authorization are implied by CI setup.
Firefox/WebKit alternatives remain opt-in and are not certified by Chromium CI.
+2 -2
View File
@@ -33,7 +33,7 @@ async def list_tools() -> list[Tool]:
"prompt": {"type": "string", "description": "Image description prompt"}, "prompt": {"type": "string", "description": "Image description prompt"},
"model": {"type": "string", "description": "Model name (auto-detects if omitted)"}, "model": {"type": "string", "description": "Model name (auto-detects if omitted)"},
"size": {"type": "string", "description": "Image size (default 1024x1024)"}, "size": {"type": "string", "description": "Image size (default 1024x1024)"},
"quality": {"type": "string", "description": "Quality: low, medium, high, auto (default configured quality, otherwise medium)"}, "quality": {"type": "string", "description": "Quality: low, medium, high, auto (default medium)"},
}, },
"required": ["prompt"], "required": ["prompt"],
}, },
@@ -66,7 +66,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if not model_spec: if not model_spec:
model_spec = _settings.get("image_model", "") model_spec = _settings.get("image_model", "")
if "quality" not in arguments and _settings.get("image_quality"): if quality == "medium" and _settings.get("image_quality"):
quality = _settings["image_quality"] quality = _settings["image_quality"]
# Auto-detect best available image model # Auto-detect best available image model
-4
View File
@@ -887,8 +887,6 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
"""Create a new integration (admin only).""" """Create a new integration (admin only)."""
_require_admin_user(request) _require_admin_user(request)
body = await request.json() body = await request.json()
if not isinstance(body, dict):
raise HTTPException(400, "Request body must be a JSON object")
item = add_integration(body) item = add_integration(body)
return {"ok": True, "integration": mask_integration_secret(item)} return {"ok": True, "integration": mask_integration_secret(item)}
@@ -897,8 +895,6 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
"""Update an existing integration (admin only).""" """Update an existing integration (admin only)."""
_require_admin_user(request) _require_admin_user(request)
body = await request.json() body = await request.json()
if not isinstance(body, dict):
raise HTTPException(400, "Request body must be a JSON object")
item = update_integration(integration_id, body) item = update_integration(integration_id, body)
if not item: if not item:
raise HTTPException(404, "Integration not found") raise HTTPException(404, "Integration not found")
+16 -16
View File
@@ -80,18 +80,6 @@ def _require_user(request: Request) -> str:
return FALLBACK_OWNER return FALLBACK_OWNER
async def _json_object_body(request: Request) -> dict:
"""Parse a JSON object body. An unreadable body counts as empty; a body
that is valid JSON but not an object (a list, string or number) is a 400."""
try:
body = await request.json()
except Exception:
return {}
if not isinstance(body, dict):
raise HTTPException(400, "Request body must be a JSON object")
return body
def _get_or_404_calendar(db, cal_id: str, owner: str) -> CalendarCal: def _get_or_404_calendar(db, cal_id: str, owner: str) -> CalendarCal:
cal = db.query(CalendarCal).filter(CalendarCal.id == cal_id).first() cal = db.query(CalendarCal).filter(CalendarCal.id == cal_id).first()
if not cal: if not cal:
@@ -1006,7 +994,10 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
async def save_config(request: Request): async def save_config(request: Request):
"""Legacy single-account endpoint — upserts the first account.""" """Legacy single-account endpoint — upserts the first account."""
owner = _require_user(request) owner = _require_user(request)
body = await _json_object_body(request) try:
body = await request.json()
except Exception:
body = {}
accounts = _get_caldav_accounts(owner) accounts = _get_caldav_accounts(owner)
if not (body.get("url") or "").strip(): if not (body.get("url") or "").strip():
_save_caldav_accounts(owner, []) _save_caldav_accounts(owner, [])
@@ -1061,7 +1052,10 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
"""Add a new CalDAV account.""" """Add a new CalDAV account."""
import uuid as _uuid import uuid as _uuid
owner = _require_user(request) owner = _require_user(request)
body = await _json_object_body(request) try:
body = await request.json()
except Exception:
body = {}
from src.caldav_sync import validate_caldav_url from src.caldav_sync import validate_caldav_url
try: try:
url = validate_caldav_url(body.get("url", "")) url = validate_caldav_url(body.get("url", ""))
@@ -1086,7 +1080,10 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
async def update_caldav_account(account_id: str, request: Request): async def update_caldav_account(account_id: str, request: Request):
"""Update an existing CalDAV account by id.""" """Update an existing CalDAV account by id."""
owner = _require_user(request) owner = _require_user(request)
body = await _json_object_body(request) try:
body = await request.json()
except Exception:
body = {}
accounts = _get_caldav_accounts(owner) accounts = _get_caldav_accounts(owner)
idx = next((i for i, a in enumerate(accounts) if a.get("id") == account_id), None) idx = next((i for i, a in enumerate(accounts) if a.get("id") == account_id), None)
if idx is None: if idx is None:
@@ -1127,7 +1124,10 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
test an already-saved account. Falls back to the first saved account test an already-saved account. Falls back to the first saved account
when nothing is provided.""" when nothing is provided."""
owner = _require_user(request) owner = _require_user(request)
body = await _json_object_body(request) try:
body = await request.json()
except Exception:
body = {}
url = (body.get("url") or "").strip() url = (body.get("url") or "").strip()
user = (body.get("username") or "").strip() user = (body.get("username") or "").strip()
pw = body.get("password") or "" pw = body.get("password") or ""
+1 -5
View File
@@ -14,7 +14,6 @@ from fastapi import HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from routes._validators import validate_remote_host, validate_ssh_port from routes._validators import validate_remote_host, validate_ssh_port
from routes.cookbook_output import HF_PARTIAL_BLOB_HELPER
from core.platform_compat import _ssh_exec_argv from core.platform_compat import _ssh_exec_argv
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -379,7 +378,6 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
""" """
lines = [ lines = [
"import json, os, re, shutil, subprocess, urllib.request", "import json, os, re, shutil, subprocess, urllib.request",
*HF_PARTIAL_BLOB_HELPER.splitlines(),
"models = []", "models = []",
"seen = set()", "seen = set()",
"BLOCKED_ROOTS = ('/sys', '/proc', '/dev', '/run', '/var/run')", "BLOCKED_ROOTS = ('/sys', '/proc', '/dev', '/run', '/var/run')",
@@ -438,10 +436,8 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
" sz, nf, ic = 0, 0, False", " sz, nf, ic = 0, 0, False",
" if os.path.isdir(blobs):", " if os.path.isdir(blobs):",
" for f in os.scandir(blobs):", " for f in os.scandir(blobs):",
" if f.name.endswith('.incomplete'):",
" if not is_partial_blob(f.path): continue",
" ic = True",
" if f.is_file(): nf += 1; sz += f.stat().st_size", " if f.is_file(): nf += 1; sz += f.stat().st_size",
" if f.name.endswith('.incomplete'): ic = True",
" snap = os.path.join(cache, d, 'snapshots')", " snap = os.path.join(cache, d, 'snapshots')",
" def snapshot_size():", " def snapshot_size():",
" total, count, incomplete = 0, 0, False", " total, count, incomplete = 0, 0, False",
+4 -18
View File
@@ -8,20 +8,6 @@ import re
_FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE) _FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
# Shared source embedded in local/remote inventory and status probes. A blank
# retry marker beside its completed, nonempty content-addressed blob is stale;
# a blank marker alone may be a download that has not received its first byte.
HF_PARTIAL_BLOB_HELPER = """def is_partial_blob(path):
if not path.endswith('.incomplete'):
return False
try:
completed = path[:-len('.incomplete')]
return (os.path.getsize(path) != 0 or not os.path.isfile(completed)
or os.path.getsize(completed) == 0)
except OSError:
return True
"""
# Probe scripts for the dead-session download check, run as # Probe scripts for the dead-session download check, run as
# `python3 -c <PROBE> <repo_id> <cache_root>` (locally or over SSH). # `python3 -c <PROBE> <repo_id> <cache_root>` (locally or over SSH).
# cache_root is the task's custom download dir, '' for the default HF cache. # cache_root is the task's custom download dir, '' for the default HF cache.
@@ -29,7 +15,7 @@ HF_PARTIAL_BLOB_HELPER = """def is_partial_blob(path):
# HF_HOME=<local_dir>, so that task's cache lives under <local_dir>/hub, and # HF_HOME=<local_dir>, so that task's cache lives under <local_dir>/hub, and
# the probe process's own environment knows nothing about it. # the probe process's own environment knows nothing about it.
HF_CACHE_COMPLETE_PROBE = ( HF_CACHE_COMPLETE_PROBE = (
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER + "import os,sys;"
"repo=sys.argv[1];" "repo=sys.argv[1];"
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';" "root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));" "base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
@@ -38,18 +24,18 @@ HF_CACHE_COMPLETE_PROBE = (
"ok=os.path.isdir(snap) and any(os.path.isdir(os.path.join(snap,x)) and os.listdir(os.path.join(snap,x)) for x in os.listdir(snap));" "ok=os.path.isdir(snap) and any(os.path.isdir(os.path.join(snap,x)) and os.listdir(os.path.join(snap,x)) for x in os.listdir(snap));"
"inc=False;" "inc=False;"
"blobs=os.path.join(d,'blobs');" "blobs=os.path.join(d,'blobs');"
"inc=os.path.isdir(blobs) and any(is_partial_blob(os.path.join(blobs,x)) for x in os.listdir(blobs));" "inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
"sys.exit(0 if ok and not inc else 1)" "sys.exit(0 if ok and not inc else 1)"
) )
HF_CACHE_INCOMPLETE_PROBE = ( HF_CACHE_INCOMPLETE_PROBE = (
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER + "import os,sys;"
"repo=sys.argv[1];" "repo=sys.argv[1];"
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';" "root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));" "base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
"d=os.path.join(base,'models--'+repo.replace('/','--'));" "d=os.path.join(base,'models--'+repo.replace('/','--'));"
"blobs=os.path.join(d,'blobs');" "blobs=os.path.join(d,'blobs');"
"inc=os.path.isdir(blobs) and any(is_partial_blob(os.path.join(blobs,x)) for x in os.listdir(blobs));" "inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
"sys.exit(0 if inc else 1)" "sys.exit(0 if inc else 1)"
) )
+2 -3
View File
@@ -4302,7 +4302,7 @@ def setup_cookbook_routes() -> APIRouter:
tmux output can stop at a stale progress line if the pane/session tmux output can stop at a stale progress line if the pane/session
disappears before Cookbook captures the final DOWNLOAD_OK marker. disappears before Cookbook captures the final DOWNLOAD_OK marker.
In that case, trust the cache shape: a snapshot directory with files In that case, trust the cache shape: a snapshot directory with files
and no unfinished blobs means HuggingFace finished materializing the and no *.incomplete blobs means HuggingFace finished materializing the
model. cache_root is the task's custom download dir — the runner model. cache_root is the task's custom download dir — the runner
pointed HF_HOME there, so the cache lives under <cache_root>/hub, pointed HF_HOME there, so the cache lives under <cache_root>/hub,
not wherever this probe's environment says. not wherever this probe's environment says.
@@ -4327,8 +4327,7 @@ def setup_cookbook_routes() -> APIRouter:
"""Best-effort check for resumable HF partial blobs. """Best-effort check for resumable HF partial blobs.
A lost SSH/tmux session can leave a real download still incomplete. A lost SSH/tmux session can leave a real download still incomplete.
Treat unfinished blobs (excluding empty markers paired with a Treat any *.incomplete blob as stronger evidence than stale
completed blob) as stronger evidence than stale
"100%" lines in the captured pane output. "100%" lines in the captured pane output.
""" """
if not repo_id or "/" not in repo_id: if not repo_id or "/" not in repo_id:
+1 -3
View File
@@ -781,11 +781,9 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
except Exception as e: except Exception as e:
logger.warning("Failed to parse export request body, defaulting to empty", exc_info=e) logger.warning("Failed to parse export request body, defaulting to empty", exc_info=e)
data = {} data = {}
ids = data.get("ids") if isinstance(data, dict) else None ids = data.get("ids") or []
if not ids: if not ids:
raise HTTPException(400, "No documents specified") raise HTTPException(400, "No documents specified")
if not isinstance(ids, list) or not all(isinstance(i, str) for i in ids):
raise HTTPException(400, "ids must be a list of document ids")
_ext = { _ext = {
"javascript": ".js", "python": ".py", "html": ".html", "css": ".css", "javascript": ".js", "python": ".py", "html": ".html", "css": ".css",
"markdown": ".md", "json": ".json", "yaml": ".yml", "bash": ".sh", "markdown": ".md", "json": ".json", "yaml": ".yml", "bash": ".sh",
+2 -3
View File
@@ -19,10 +19,9 @@ def _split_family_token(token):
def _derive_family(filename): def _derive_family(filename):
"""Derive a font-family name from a filename like 'JetBrainsMono-Regular.woff2' → 'JetBrains Mono'.""" """Derive a font-family name from a filename like 'JetBrainsMono-Regular.woff2' → 'JetBrains Mono'."""
name = os.path.splitext(filename)[0] name = os.path.splitext(filename)[0]
# Strip weight/style suffixes, including BoldItalic and Bold-Italic. # Strip common weight/style suffixes
name = re.sub( name = re.sub(
r'[-_ ]?(Thin|ExtraLight|UltraLight|Light|Regular|Medium|SemiBold|DemiBold|Bold|ExtraBold|UltraBold|Black|Heavy|Italic|Oblique|Variable|VF)' r'[-_ ]?(Thin|ExtraLight|UltraLight|Light|Regular|Medium|SemiBold|DemiBold|Bold|ExtraBold|UltraBold|Black|Heavy|Italic|Oblique|Variable|VF)$',
r'(?:[-_ ]?(?:Italic|Oblique))?$',
'', name, flags=re.IGNORECASE '', name, flags=re.IGNORECASE
) )
# Replace dashes/underscores with spaces # Replace dashes/underscores with spaces
+11 -6
View File
@@ -2433,10 +2433,11 @@ def setup_gallery_routes() -> APIRouter:
image_b64 = body.get("image") image_b64 = body.get("image")
amount = body.get("amount", 50) / 100.0 amount = body.get("amount", 50) / 100.0
from PIL import ImageFilter from PIL import Image, ImageFilter
import base64, io import base64, io
img = _b64_to_pil_image(image_b64, mode="RGB") img_bytes = _b64_bytes(image_b64)
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
# Unsharp mask: radius=2, percent=amount*200, threshold=3 # Unsharp mask: radius=2, percent=amount*200, threshold=3
sharpened = img.filter(ImageFilter.UnsharpMask(radius=2, percent=int(amount * 200), threshold=3)) sharpened = img.filter(ImageFilter.UnsharpMask(radius=2, percent=int(amount * 200), threshold=3))
@@ -2471,7 +2472,8 @@ def setup_gallery_routes() -> APIRouter:
except ImportError: except ImportError:
raise HTTPException(500, "Server missing a required dependency") raise HTTPException(500, "Server missing a required dependency")
# Decode source image (RGB; Real-ESRGAN doesn't preserve alpha). # Decode source image (RGB; Real-ESRGAN doesn't preserve alpha).
src = _b64_to_pil_image(image_b64, mode="RGB") img_bytes = _b64_bytes(image_b64)
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
try: try:
patch_realesrgan_torchvision_compat() patch_realesrgan_torchvision_compat()
from realesrgan import RealESRGANer from realesrgan import RealESRGANer
@@ -2523,7 +2525,8 @@ def setup_gallery_routes() -> APIRouter:
import numpy as np import numpy as np
except ImportError: except ImportError:
raise HTTPException(500, "Server missing a required dependency") raise HTTPException(500, "Server missing a required dependency")
src = _b64_to_pil_image(image_b64, mode="RGB") img_bytes = _b64_bytes(image_b64)
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
try: try:
patch_realesrgan_torchvision_compat() patch_realesrgan_torchvision_compat()
from basicsr.archs.rrdbnet_arch import RRDBNet from basicsr.archs.rrdbnet_arch import RRDBNet
@@ -2775,7 +2778,8 @@ def setup_gallery_routes() -> APIRouter:
from PIL import Image from PIL import Image
import base64, io import base64, io
img = _b64_to_pil_image(image_b64) img_bytes = _b64_bytes(image_b64)
img = Image.open(io.BytesIO(img_bytes)).convert("RGBA")
W, H = img.size W, H = img.size
hint = None hint = None
@@ -2864,7 +2868,8 @@ def setup_gallery_routes() -> APIRouter:
from PIL import Image, ImageFilter, ImageEnhance from PIL import Image, ImageFilter, ImageEnhance
import numpy as np import numpy as np
img = _b64_to_pil_image(image_b64, mode="RGB") img_bytes = _b64_bytes(image_b64)
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
# Try GFPGAN first (AI face restoration) # Try GFPGAN first (AI face restoration)
try: try:
+9 -12
View File
@@ -728,35 +728,32 @@ def setup_note_routes(task_scheduler=None, upload_handler=None):
) )
if body.title is not None: if body.title is not None:
note.title = body.title note.title = body.title
# Omitted fields preserve their values; explicit null clears the if body.content is not None:
# nullable fields used by the editor's removal controls.
supplied = body.model_fields_set
if "content" in supplied:
note.content = body.content note.content = body.content
if "items" in supplied: if body.items is not None:
note.items = json.dumps(body.items) if body.items is not None else None note.items = json.dumps(body.items)
flag_modified(note, "items") flag_modified(note, "items")
if body.note_type is not None: if body.note_type is not None:
note.note_type = body.note_type note.note_type = body.note_type
if "color" in supplied: if body.color is not None:
note.color = body.color note.color = body.color
if "label" in supplied: if body.label is not None:
note.label = body.label note.label = body.label
if body.pinned is not None: if body.pinned is not None:
note.pinned = body.pinned note.pinned = body.pinned
if body.archived is not None: if body.archived is not None:
note.archived = body.archived note.archived = body.archived
if "due_date" in supplied: if body.due_date is not None:
note.due_date = body.due_date note.due_date = body.due_date
if "image_url" in supplied: if body.image_url is not None:
note.image_url = body.image_url note.image_url = body.image_url
if "gallery_id" in supplied: if body.gallery_id is not None:
note.gallery_id = body.gallery_id note.gallery_id = body.gallery_id
if body.repeat is not None: if body.repeat is not None:
note.repeat = body.repeat note.repeat = body.repeat
if body.sort_order is not None: if body.sort_order is not None:
note.sort_order = body.sort_order note.sort_order = body.sort_order
if "agent_session_id" in supplied: if body.agent_session_id is not None:
note.agent_session_id = body.agent_session_id note.agent_session_id = body.agent_session_id
db.commit() db.commit()
+40 -7
View File
@@ -1315,7 +1315,7 @@ def setup_session_routes(
def auto_sort_sessions(request: Request, skip_llm: bool = False): def auto_sort_sessions(request: Request, skip_llm: bool = False):
"""Use AI to categorize all sessions into folders. """Use AI to categorize all sessions into folders.
Phase 1 deletes empty/Incognito sessions and Phase 2 asks the LLM Phase 1 deletes empty/throwaway sessions and Phase 2 asks the LLM
to assign folders. When `skip_llm=true` the endpoint returns to assign folders. When `skip_llm=true` the endpoint returns
after Phase 1 — used by the "Tidy (no AI)" UI affordance so after Phase 1 — used by the "Tidy (no AI)" UI affordance so
users can clean junk without spending tokens. users can clean junk without spending tokens.
@@ -1326,11 +1326,21 @@ def setup_session_routes(
single_user_mode = not user and _auth_disabled() single_user_mode = not user and _auth_disabled()
user_sessions = session_manager.get_sessions_for_user(user) user_sessions = session_manager.get_sessions_for_user(user)
# Saved conversations are never disposable based on their title or prompt. # Delete empty and throwaway sessions before sorting
from core.database import ChatMessage as DbMsg from core.database import ChatMessage as DbMsg
db = SessionLocal() db = SessionLocal()
deleted_empty = 0 deleted_empty = 0
deleted_throwaway = 0 deleted_throwaway = 0
# Names that indicate a throwaway/test session (case-insensitive exact or prefix match)
_THROWAWAY_NAMES = {
"test", "testing", "asdf", "asd", "hello", "hi", "hey",
"yo", "sup", "hola", "hii", "hiii", "heyo",
"foo", "bar", "baz", "tmp", "temp", "scratch", "untitled",
"new chat", "delete", "remove", "junk", "trash", "xxx",
"abc", "qwerty", "blah", "stuff", "whatever", "idk",
"ok", "lol", "bruh", "hmm", "hm", "meh",
}
_THROWAWAY_MAX_MESSAGES = 4 # only delete if <= this many messages
try: try:
rows_q = db.query(DbSession).filter(DbSession.archived == False) rows_q = db.query(DbSession).filter(DbSession.archived == False)
if user: if user:
@@ -1339,11 +1349,15 @@ def setup_session_routes(
rows_q = rows_q.filter(DbSession.owner == user) rows_q = rows_q.filter(DbSession.owner == user)
rows = rows_q.limit(2000).all() rows = rows_q.limit(2000).all()
folder_map = {r.id: r.folder for r in rows} folder_map = {r.id: r.folder for r in rows}
# Precompute per-session message counts in one aggregate query # Precompute per-session message counts in TWO aggregate queries
# instead of 1–3 queries PER session — with many chats the per-row # instead of 1–3 queries PER session — with many chats the per-row
# loop was doing thousands of round-trips and blowing the timeout. # loop was doing thousands of round-trips and blowing the timeout.
from sqlalchemy import func as _sa_func from sqlalchemy import func as _sa_func
_counts = dict(db.query(DbMsg.session_id, _sa_func.count(DbMsg.id)).group_by(DbMsg.session_id).all()) _counts = dict(db.query(DbMsg.session_id, _sa_func.count(DbMsg.id)).group_by(DbMsg.session_id).all())
_asst_counts = dict(
db.query(DbMsg.session_id, _sa_func.count(DbMsg.id))
.filter(DbMsg.role == "assistant").group_by(DbMsg.session_id).all()
)
cleanup_now = utcnow_naive() cleanup_now = utcnow_naive()
for row in rows: for row in rows:
# Never delete important sessions # Never delete important sessions
@@ -1353,10 +1367,9 @@ def setup_session_routes(
if (row.name or "").strip() == "Incognito": if (row.name or "").strip() == "Incognito":
should_delete = True should_delete = True
deleted_throwaway += 1 deleted_throwaway += 1
db.delete(row)
if hasattr(session_manager, 'delete_session'): if hasattr(session_manager, 'delete_session'):
session_manager.delete_session(row.id) session_manager.delete_session(row.id)
else:
db.delete(row)
continue continue
if is_session_recently_active(row, now=cleanup_now): if is_session_recently_active(row, now=cleanup_now):
continue continue
@@ -1365,11 +1378,31 @@ def setup_session_routes(
if msg_count == 0: if msg_count == 0:
should_delete = True should_delete = True
deleted_empty += 1 deleted_empty += 1
elif msg_count <= _THROWAWAY_MAX_MESSAGES:
name = (row.name or "").strip().lower()
# Check first user message content (AI renames sessions, so
# "hi" becomes "Casual Greeting Exchange" — name alone won't match)
first_msg = db.query(DbMsg.content).filter(
DbMsg.session_id == row.id, DbMsg.role == "user"
).order_by(DbMsg.timestamp).first()
first_text = (first_msg[0] or "").strip().lower() if first_msg else ""
# Count assistant messages — if user sent something but AI never replied, it's dead
assistant_count = _asst_counts.get(row.id, 0)
if name in _THROWAWAY_NAMES or name.startswith("chat:") or first_text in _THROWAWAY_NAMES:
should_delete = True
deleted_throwaway += 1
# Single user message with no AI response = dead session
elif msg_count == 1 and assistant_count == 0:
should_delete = True
deleted_throwaway += 1
# Short phrase (1-3 words) with no real AI conversation (<=2 msgs)
elif msg_count <= 2 and first_text and len(first_text.split()) <= 3 and len(first_text) <= 40:
should_delete = True
deleted_throwaway += 1
if should_delete: if should_delete:
db.delete(row)
if hasattr(session_manager, 'delete_session'): if hasattr(session_manager, 'delete_session'):
session_manager.delete_session(row.id) session_manager.delete_session(row.id)
else:
db.delete(row)
if deleted_empty or deleted_throwaway: if deleted_empty or deleted_throwaway:
db.commit() db.commit()
logger.info(f"Auto-sort: deleted {deleted_empty} empty + {deleted_throwaway} throwaway sessions") logger.info(f"Auto-sort: deleted {deleted_empty} empty + {deleted_throwaway} throwaway sessions")
+9 -3
View File
@@ -20,7 +20,7 @@ from src.task_action_policy import (
is_admin_only_task_action, is_admin_only_task_action,
owner_has_admin_task_privileges, owner_has_admin_task_privileges,
) )
from src.task_scheduler import compute_next_run, is_valid_cron, HOUSEKEEPING_DEFAULTS from src.task_scheduler import compute_next_run, HOUSEKEEPING_DEFAULTS
from routes.prefs_routes import _load_for_user, _save_for_user from routes.prefs_routes import _load_for_user, _save_for_user
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -475,7 +475,10 @@ def setup_task_routes(task_scheduler) -> APIRouter:
if req.trigger_type == "schedule" and req.schedule == "cron" and not req.cron_expression: if req.trigger_type == "schedule" and req.schedule == "cron" and not req.cron_expression:
raise HTTPException(400, "Cron expression is required for cron schedule") raise HTTPException(400, "Cron expression is required for cron schedule")
if req.trigger_type == "schedule" and req.schedule == "cron" and req.cron_expression: if req.trigger_type == "schedule" and req.schedule == "cron" and req.cron_expression:
if not is_valid_cron(req.cron_expression): try:
from croniter import croniter
croniter(req.cron_expression)
except Exception:
raise HTTPException(400, "Invalid cron expression") raise HTTPException(400, "Invalid cron expression")
if req.trigger_type == "event" and not req.trigger_event: if req.trigger_type == "event" and not req.trigger_event:
raise HTTPException(400, "Event name is required for event-triggered tasks") raise HTTPException(400, "Event name is required for event-triggered tasks")
@@ -785,7 +788,10 @@ def setup_task_routes(task_scheduler) -> APIRouter:
task.character_id = req.character_id or None task.character_id = req.character_id or None
if req.cron_expression is not None: if req.cron_expression is not None:
if req.cron_expression: if req.cron_expression:
if not is_valid_cron(req.cron_expression): try:
from croniter import croniter
croniter(req.cron_expression)
except Exception:
raise HTTPException(400, "Invalid cron expression") raise HTTPException(400, "Invalid cron expression")
task.cron_expression = req.cron_expression or None task.cron_expression = req.cron_expression or None
+2 -9
View File
@@ -6,7 +6,6 @@ that are awkward at the shell). Filters images by tag, prompt, favorite,
album, and surfaces EXIF metadata. album, and surfaces EXIF metadata.
odysseus-gallery list --limit 20 --favorites odysseus-gallery list --limit 20 --favorites
odysseus-gallery list --tag "sunset,beach" # all tags, manual or AI
odysseus-gallery show IMAGE_ID odysseus-gallery show IMAGE_ID
odysseus-gallery albums | jq -r '.[] | .name' odysseus-gallery albums | jq -r '.[] | .name'
odysseus-gallery search "sunset" # matches prompt + tags odysseus-gallery search "sunset" # matches prompt + tags
@@ -84,13 +83,7 @@ def cmd_list(args):
fail(f"no album named {args.album!r}") fail(f"no album named {args.album!r}")
q = q.filter(GalleryImage.album_id == al.id) q = q.filter(GalleryImage.album_id == al.id)
if args.tag: if args.tag:
for tag in (value.strip() for value in args.tag.split(",")): q = q.filter(GalleryImage.tags.ilike(f"%{args.tag}%"))
if not tag:
continue
q = q.filter(
GalleryImage.tags.ilike(f"%{tag}%") |
GalleryImage.ai_tags.ilike(f"%{tag}%")
)
q = q.order_by(GalleryImage.created_at.desc()).limit(args.limit) q = q.order_by(GalleryImage.created_at.desc()).limit(args.limit)
emit([_serialize_image(i) for i in q.all()], args) emit([_serialize_image(i) for i in q.all()], args)
finally: finally:
@@ -166,7 +159,7 @@ def _build_parser():
pl.add_argument("--limit", type=int, default=50) pl.add_argument("--limit", type=int, default=50)
pl.add_argument("--favorites", action="store_true") pl.add_argument("--favorites", action="store_true")
pl.add_argument("--album", help="filter by album name") pl.add_argument("--album", help="filter by album name")
pl.add_argument("--tag", help="comma-separated tags; each matches manual or AI tags by substring") pl.add_argument("--tag", help="substring match against tags column")
pl.set_defaults(func=cmd_list) pl.set_defaults(func=cmd_list)
psh = sub.add_parser("show", parents=[common]) psh = sub.add_parser("show", parents=[common])
+6 -12
View File
@@ -43,7 +43,7 @@ import logging
import os import os
import re import re
import sys import sys
from datetime import datetime, timezone from datetime import datetime
from pathlib import Path from pathlib import Path
# Anchor the path so the script works when invoked from anywhere. # Anchor the path so the script works when invoked from anywhere.
@@ -136,7 +136,7 @@ def cmd_list(args) -> None:
emit([], args) emit([], args)
return return
all_uids = data[0].split() all_uids = data[0].split()
# Keep a bounded arrival batch; order its decoded rows by Date below. # Newest first, limit
uids = list(reversed(all_uids))[: args.limit] uids = list(reversed(all_uids))[: args.limit]
out = [] out = []
for uid in uids: for uid in uids:
@@ -165,15 +165,10 @@ def cmd_list(args) -> None:
sender_name, sender_addr = parseaddr(sender) sender_name, sender_addr = parseaddr(sender)
date_raw = msg.get("Date", "") date_raw = msg.get("Date", "")
try: try:
parsed_date = parsedate_to_datetime(date_raw) if date_raw else None iso = parsedate_to_datetime(date_raw).isoformat() if date_raw else ""
iso = parsed_date.isoformat() if parsed_date else ""
if parsed_date is not None and parsed_date.tzinfo is None:
parsed_date = parsed_date.replace(tzinfo=timezone.utc)
date_epoch = parsed_date.timestamp() if parsed_date else float("-inf")
except Exception: except Exception:
iso = "" iso = ""
date_epoch = float("-inf") out.append({
out.append((date_epoch, {
"uid": uid.decode(), "uid": uid.decode(),
"date": iso, "date": iso,
"from_addr": sender_addr, "from_addr": sender_addr,
@@ -181,11 +176,10 @@ def cmd_list(args) -> None:
"subject": subject, "subject": subject,
"is_read": "\\Seen" in flags, "is_read": "\\Seen" in flags,
"is_answered": "\\Answered" in flags, "is_answered": "\\Answered" in flags,
})) })
except Exception as e: except Exception as e:
sys.stderr.write(f"warn: skipping uid {uid!r}: {e}\n") sys.stderr.write(f"warn: skipping uid {uid!r}: {e}\n")
out.sort(key=lambda item: item[0], reverse=True) emit(out, args)
emit([row for _, row in out], args)
# ─── read ──────────────────────────────────────────────────────────── # ─── read ────────────────────────────────────────────────────────────
+15 -22
View File
@@ -77,14 +77,13 @@ def sanitize_body_text(text: str) -> str:
# 1. Try JSON # 1. Try JSON
trimmed = text.strip() trimmed = text.strip()
if trimmed.startswith(("{", "[")): if (trimmed.startswith("{") and trimmed.endswith("}")) or (trimmed.startswith("[") and trimmed.endswith("]")):
try: try:
parsed = json.loads(trimmed) parsed = json.loads(trimmed)
sanitized = sanitize_structured_obj(parsed) sanitized = sanitize_structured_obj(parsed)
return json.dumps(sanitized, ensure_ascii=False) return json.dumps(sanitized, ensure_ascii=False)
except Exception as exc: except Exception:
if SENSITIVE_KEY_PATTERN.search(trimmed) or any(p in trimmed.lower() for p in SENSITIVE_PARAM_NAMES): pass
raise ValueError(f"Malformed sensitive JSON request body: {exc}") from exc
# 2. Try URL-encoded form data # 2. Try URL-encoded form data
if "=" in trimmed and not trimmed.startswith("<"): if "=" in trimmed and not trimmed.startswith("<"):
@@ -99,13 +98,8 @@ def sanitize_body_text(text: str) -> str:
else: else:
out_pairs.append((k, v)) out_pairs.append((k, v))
return urllib.parse.urlencode(out_pairs) return urllib.parse.urlencode(out_pairs)
except Exception as exc: except Exception:
if SENSITIVE_KEY_PATTERN.search(trimmed): pass
raise ValueError(f"Malformed sensitive form request body: {exc}") from exc
# If unparsed body text contains sensitive keys or tokens, fail closed
if SENSITIVE_KEY_PATTERN.search(trimmed) or any(p in trimmed.lower() for p in SENSITIVE_PARAM_NAMES):
raise ValueError("Malformed or unsupported sensitive request body in trace entry")
return text return text
@@ -213,11 +207,7 @@ def sanitize_structured_obj(obj: Any) -> Any:
def sanitize_jsonl_content(data: bytes) -> bytes: def sanitize_jsonl_content(data: bytes) -> bytes:
"""Sanitize newline-delimited JSON Lines (.trace, .network, .stacks). """Sanitize newline-delimited JSON Lines (.trace, .network, .stacks)."""
If any non-empty record cannot be decoded as valid UTF-8 or parsed as valid JSON,
or is malformed, raise ValueError fail-closed so the affected archive is permanently removed.
"""
lines = data.split(b"\n") lines = data.split(b"\n")
out_lines = [] out_lines = []
for line in lines: for line in lines:
@@ -226,8 +216,10 @@ def sanitize_jsonl_content(data: bytes) -> bytes:
continue continue
try: try:
parsed = json.loads(line.decode("utf-8")) parsed = json.loads(line.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as err: except (json.JSONDecodeError, UnicodeDecodeError):
raise ValueError(f"Malformed or unsupported JSONL trace record: {err}") # Not valid JSON, keep as-is
out_lines.append(line)
continue
sanitized = sanitize_structured_obj(parsed) sanitized = sanitize_structured_obj(parsed)
out_lines.append(json.dumps(sanitized, ensure_ascii=False).encode("utf-8")) out_lines.append(json.dumps(sanitized, ensure_ascii=False).encode("utf-8"))
return b"\n".join(out_lines) return b"\n".join(out_lines)
@@ -283,10 +275,11 @@ def sanitize_trace_archive(zp: str) -> bool:
elif filename_lower.endswith((".json", ".har")): elif filename_lower.endswith((".json", ".har")):
try: try:
parsed = json.loads(content.decode("utf-8")) parsed = json.loads(content.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as exc: sanitized = sanitize_structured_obj(parsed)
raise ValueError(f"Malformed JSON in archive member {item.filename}: {exc}") from exc zout.writestr(item, json.dumps(sanitized, ensure_ascii=False).encode("utf-8"))
sanitized = sanitize_structured_obj(parsed) except Exception:
zout.writestr(item, json.dumps(sanitized, ensure_ascii=False).encode("utf-8")) # If invalid JSON, treat as text line-by-line
zout.writestr(item, sanitize_jsonl_content(content))
else: else:
# Unknown binary or text member: exclude to fail closed # Unknown binary or text member: exclude to fail closed
continue continue
+6 -36
View File
@@ -11,38 +11,6 @@ import time
from urllib.request import urlopen from urllib.request import urlopen
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def _stop_owned_server(server, identity):
"""Close only identity-bound descendants, including MCP's new sessions.
Capture before signalling the parent: an orphaned stderr holder can no
longer be attributed by ancestry. Never search by executable name or kill
the caller's process group.
"""
from src import process_lifecycle as lifecycle
descendants = (lifecycle.bind_descendants([identity.pid])
if identity.verdict() == lifecycle.OWNED else [])
if os.name == "nt":
if identity.verdict() == lifecycle.OWNED:
lifecycle.taskkill_tree(identity.pid)
else:
outcome = lifecycle.terminate_identities(
[*(entry.identity for entry in reversed(descendants)), identity],
steps=lifecycle.term_kill_steps(10),
)
if outcome.survivors or outcome.unverified:
raise RuntimeError(f"Disposable Notes cleanup could not verify exit: {outcome}")
try:
logs, _ = server.communicate(timeout=2)
except subprocess.TimeoutExpired as error:
# Do not turn an unidentified pipe holder into an unbounded wait.
server.stdout.close()
raise RuntimeError("Disposable Notes output pipe remained open after owned cleanup") from error
return logs
def main(): def main():
@@ -68,10 +36,7 @@ def main():
server = subprocess.Popen( server = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app:app", "--host", "127.0.0.1", "--port", str(port)], [sys.executable, "-m", "uvicorn", "app:app", "--host", "127.0.0.1", "--port", str(port)],
cwd=target, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=target, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
start_new_session=(os.name != "nt"),
) )
from src.process_lifecycle import ProcessIdentity
server_identity = ProcessIdentity.capture(server.pid)
result = None result = None
try: try:
origin = f"http://127.0.0.1:{port}" origin = f"http://127.0.0.1:{port}"
@@ -97,7 +62,12 @@ def main():
print(result.stderr, file=sys.stderr) print(result.stderr, file=sys.stderr)
return result.returncode return result.returncode
finally: finally:
logs = _stop_owned_server(server, server_identity) server.terminate()
try:
logs, _ = server.communicate(timeout=10)
except subprocess.TimeoutExpired:
server.kill()
logs, _ = server.communicate()
if result is None or result.returncode: if result is None or result.returncode:
print(logs[-8000:], file=sys.stderr) print(logs[-8000:], file=sys.stderr)
+2 -20
View File
@@ -202,35 +202,17 @@ def _infer_use_case(repo_id, pipeline_tag):
return "general" return "general"
# HF collections mix in models no Cookbook LLM backend can serve (vision
# encoders, time-series forecasters, image/video generators). Image models
# have their own registry, so keep these out of the LLM catalog.
_NON_LLM_PIPELINE_TAGS = frozenset({
"time-series-forecasting",
"zero-shot-image-classification",
"image-classification",
"image-feature-extraction",
"text-to-image",
"image-to-video",
"text-to-video",
})
def _entry_from_collection_item(collection, item, source): def _entry_from_collection_item(collection, item, source):
repo_id = item.get("id") or "" repo_id = item.get("id") or ""
if item.get("type") != "model" or not repo_id: if item.get("type") != "model" or not repo_id:
return None return None
if (item.get("pipeline_tag") or "").strip().lower() in _NON_LLM_PIPELINE_TAGS:
return None
repo_prefix = source.get("repo_prefix") repo_prefix = source.get("repo_prefix")
if repo_prefix and not repo_id.startswith(repo_prefix): if repo_prefix and not repo_id.startswith(repo_prefix):
return None return None
raw_params = item.get("numParameters") or 0 raw_params = item.get("numParameters") or 0
# Read the name even when HF gives a total: it is the only source of the active = None
# MoE active size (e.g. "-35B-A3B"), which the speed estimate depends on.
name_params, active = _parse_params_from_name(repo_id)
if not raw_params: if not raw_params:
raw_params = name_params raw_params, active = _parse_params_from_name(repo_id)
param_label, raw_params = _format_params(raw_params) param_label, raw_params = _format_params(raw_params)
if not raw_params: if not raw_params:
return None return None
+2 -6
View File
@@ -84,14 +84,10 @@ _FM_KEY_RE = re.compile(r"^([a-z_][a-z0-9_]*):\s*(.*)$", re.IGNORECASE)
_FM_BLOCK_LIST_RE = re.compile(r"^\s*-\s*(.*)$") _FM_BLOCK_LIST_RE = re.compile(r"^\s*-\s*(.*)$")
def _parse_scalar(raw: str, *, string_only: bool = False) -> Any: def _parse_scalar(raw: str) -> Any:
raw = raw.strip() raw = raw.strip()
if raw == "": if raw == "":
return "" return ""
# String fields keep their spelling; quoted values still use the decoder
# below so existing escapes round-trip as before.
if string_only and not (raw[0] == raw[-1] and raw[0] in ("'", '"')):
return raw
if raw.startswith("[") and raw.endswith("]"): if raw.startswith("[") and raw.endswith("]"):
inner = raw[1:-1].strip() inner = raw[1:-1].strip()
if not inner: if not inner:
@@ -175,7 +171,7 @@ def parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
pending_key = key pending_key = key
fm[key] = [] fm[key] = []
else: else:
fm[key] = _parse_scalar(val, string_only=(key == "version")) fm[key] = _parse_scalar(val)
pending_key = None pending_key = None
continue continue
m2 = _FM_BLOCK_LIST_RE.match(line) m2 = _FM_BLOCK_LIST_RE.match(line)
+8 -8
View File
@@ -1207,7 +1207,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
Line 1: prompt describing the image Line 1: prompt describing the image
Line 2: model name (optional, default auto-detects: prefers gpt-image-1.5 > gpt-image-1) Line 2: model name (optional, default auto-detects: prefers gpt-image-1.5 > gpt-image-1)
Line 3: size (optional, defaults to 1024x1024) Line 3: size (optional, defaults to 1024x1024)
Line 4: quality (optional, defaults to configured quality or medium — low, medium, high, auto) Line 4: quality (optional, defaults to medium — options: low, medium, high, auto)
""" """
import base64 import base64
import httpx import httpx
@@ -1225,13 +1225,13 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
prompt = str(args.get('prompt') or '').strip() prompt = str(args.get('prompt') or '').strip()
model_spec = str(args.get('model') or '').strip() model_spec = str(args.get('model') or '').strip()
size = str(args.get('size') or '1024x1024') size = str(args.get('size') or '1024x1024')
quality = str(args.get('quality') or '') quality = str(args.get('quality') or 'medium')
else: else:
lines = content.strip().split("\n") lines = content.strip().split("\n")
prompt = lines[0].strip() if lines else "" prompt = lines[0].strip() if lines else ""
model_spec = lines[1].strip() if len(lines) > 1 and lines[1].strip() else "" model_spec = lines[1].strip() if len(lines) > 1 and lines[1].strip() else ""
size = lines[2].strip() if len(lines) > 2 and lines[2].strip() else "1024x1024" size = lines[2].strip() if len(lines) > 2 and lines[2].strip() else "1024x1024"
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else "" quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else "medium"
if not prompt: if not prompt:
return {"error": "Image prompt is required (line 1)"} return {"error": "Image prompt is required (line 1)"}
@@ -1249,8 +1249,8 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
# Use admin-configured model/quality if not specified by the tool call # Use admin-configured model/quality if not specified by the tool call
if not model_spec: if not model_spec:
model_spec = _settings.get("image_model", "") model_spec = _settings.get("image_model", "")
if not quality: if quality == "medium" and _settings.get("image_quality"):
quality = _settings.get("image_quality") or "medium" quality = _settings["image_quality"]
# Auto-detect best available image model if still not set # Auto-detect best available image model if still not set
if not model_spec: if not model_spec:
@@ -1482,7 +1482,7 @@ async def do_edit_image(
session_id: Optional[str] = None, session_id: Optional[str] = None,
owner: Optional[str] = None, owner: Optional[str] = None,
size: str = "auto", size: str = "auto",
quality: Optional[str] = None, quality: str = "medium",
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None, progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
incognito: bool = False, incognito: bool = False,
) -> Dict: ) -> Dict:
@@ -1509,8 +1509,8 @@ async def do_edit_image(
if not model_spec: if not model_spec:
model_spec = _settings.get("image_model", "") model_spec = _settings.get("image_model", "")
if quality is None: if quality == "medium" and _settings.get("image_quality"):
quality = _settings.get("image_quality") or "medium" quality = _settings["image_quality"]
if not model_spec: if not model_spec:
return {"error": "No image model selected for image editing"} return {"error": "No image model selected for image editing"}
+4 -17
View File
@@ -432,15 +432,12 @@ class _DegenerateStreamGuard:
"Summer Summer ..."). This is not a useful response and can burn context, "Summer Summer ..."). This is not a useful response and can burn context,
browser memory, and GPU time. Keep the guard conservative: only fire on long browser memory, and GPU time. Keep the guard conservative: only fire on long
same-token runs or a very dominant repeated token in the recent window. same-token runs or a very dominant repeated token in the recent window.
Repetition made only of numbers is data (a zero or identity matrix, a
padded buffer), so it only stops at a long all-numeric backstop.
""" """
def __init__(self, model: str): def __init__(self, model: str):
self.model = model or "model" self.model = model or "model"
self.last_token = "" self.last_token = ""
self.same_run = 0 self.same_run = 0
self.numeric_run = 0
self.recent_tokens: List[str] = [] self.recent_tokens: List[str] = []
self.periodic_tokens: List[str] = [] self.periodic_tokens: List[str] = []
self.total_chars = 0 self.total_chars = 0
@@ -461,7 +458,6 @@ class _DegenerateStreamGuard:
else: else:
self.last_token = token self.last_token = token
self.same_run = 1 self.same_run = 1
self.numeric_run = self.numeric_run + 1 if token.isdigit() else 0
self.recent_tokens.append(token) self.recent_tokens.append(token)
self.periodic_tokens.append(token) self.periodic_tokens.append(token)
if len(self.recent_tokens) > 96: if len(self.recent_tokens) > 96:
@@ -470,20 +466,14 @@ class _DegenerateStreamGuard:
self.periodic_tokens = self.periodic_tokens[-2048:] self.periodic_tokens = self.periodic_tokens[-2048:]
reason = None reason = None
if self.same_run >= 28 and self.total_chars >= 100 and not self.last_token.isdigit(): if self.same_run >= 28 and self.total_chars >= 100:
reason = f"repeated '{self.last_token}' {self.same_run} times" reason = f"repeated '{self.last_token}' {self.same_run} times"
elif len(self.recent_tokens) >= 72: elif len(self.recent_tokens) >= 72:
top = max(set(self.recent_tokens), key=self.recent_tokens.count) top = max(set(self.recent_tokens), key=self.recent_tokens.count)
count = self.recent_tokens.count(top) count = self.recent_tokens.count(top)
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78 and not top.isdigit(): if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78:
reason = f"repeated '{top}' {count}/{len(self.recent_tokens)} recent tokens" reason = f"repeated '{top}' {count}/{len(self.recent_tokens)} recent tokens"
if ( if not reason and len(self.recent_tokens) >= 48:
not reason
and len(self.recent_tokens) >= 48
# A window that is mostly numbers is matrix/table data; its
# periodicity is real. Mixed loops still hit the exact-block check.
and sum(t.isdigit() for t in self.recent_tokens) * 2 < len(self.recent_tokens)
):
# Detect a periodic suffix instead of counting repeated n-grams. # Detect a periodic suffix instead of counting repeated n-grams.
# Reused property names are normal in CSS/SVG/JSON; a collapsed # Reused property names are normal in CSS/SVG/JSON; a collapsed
# generation repeats nearly every token at a short fixed period. # generation repeats nearly every token at a short fixed period.
@@ -520,13 +510,10 @@ class _DegenerateStreamGuard:
period = current_anchor - previous period = current_anchor - previous
if period < 24 or len(history) < period * 2: if period < 24 or len(history) < period * 2:
continue continue
if history[-2 * period:-period] == history[-period:] and not all(t.isdigit() for t in history[-period:]): if history[-2 * period:-period] == history[-period:]:
reason = f"repeated an exact {period}-token block twice" reason = f"repeated an exact {period}-token block twice"
break break
if not reason and self.numeric_run >= 2048:
reason = f"emitted {self.numeric_run} numbers in a row"
if not reason: if not reason:
return None return None
+1 -4
View File
@@ -193,10 +193,7 @@ class ModelDiscovery:
data = r.json() data = r.json()
# Some OpenAI-compatible servers return a bare list, not {"data": [...]}. # Some OpenAI-compatible servers return a bare list, not {"data": [...]}.
items = data if isinstance(data, list) else ((data or {}).get("data") or []) items = data if isinstance(data, list) else ((data or {}).get("data") or [])
ids = [ ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
m["id"] for m in items
if isinstance(m, dict) and isinstance(m.get("id"), str) and m["id"]
]
if ids: if ids:
return { return {
"host": host, "host": host,
+2 -4
View File
@@ -102,10 +102,8 @@ _PLAIN_FRONT_MATTER_RE = re.compile(
# - **label:** value <!-- field=NAME-ENC type=text --> # - **label:** value <!-- field=NAME-ENC type=text -->
# - **label** [opts]: value <!-- field=NAME-ENC type=choice --> # - **label** [opts]: value <!-- field=NAME-ENC type=choice -->
# - [x] **label** <!-- field=NAME-ENC type=checkbox --> # - [x] **label** <!-- field=NAME-ENC type=checkbox -->
# Accept Unicode word characters for older saved markers, whose encoder left
# non-ASCII letters/numbers literal. Newly rendered names are ASCII-safe.
_FIELD_BULLET_RE = re.compile( _FIELD_BULLET_RE = re.compile(
r'^\s*-\s+(?P<body>.*?)\s*<!--\s*field=(?P<name>[\w.%-]+)\s+type=(?P<type>\w+)\s*-->\s*$' r'^\s*-\s+(?P<body>.*?)\s*<!--\s*field=(?P<name>[A-Za-z0-9_.%-]+)\s+type=(?P<type>\w+)\s*-->\s*$'
) )
@@ -117,7 +115,7 @@ def _encode_name(name: str) -> str:
""" """
out = [] out = []
for ch in name or "": for ch in name or "":
if (ch.isascii() and ch.isalnum()) or ch in ("_", ".", "-"): if ch.isalnum() or ch in ("_", ".", "-"):
out.append(ch) out.append(ch)
else: else:
for b in ch.encode("utf-8"): for b in ch.encode("utf-8"):
+51 -8
View File
@@ -12,6 +12,16 @@ from datetime import datetime, timedelta, timezone
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Names that indicate a throwaway/test session
_THROWAWAY_NAMES = {
"test", "testing", "asdf", "asd", "hello", "hi", "hey",
"yo", "sup", "hola", "hii", "hiii", "heyo",
"foo", "bar", "baz", "tmp", "temp", "scratch", "untitled",
"new chat", "delete", "remove", "junk", "trash", "xxx",
"abc", "qwerty", "blah", "stuff", "whatever", "idk",
"ok", "lol", "bruh", "hmm", "hm", "meh",
}
_THROWAWAY_MAX_MESSAGES = 4
_FRESH_EMPTY_SESSION_GRACE = timedelta(minutes=10) _FRESH_EMPTY_SESSION_GRACE = timedelta(minutes=10)
_FRESH_SESSION_GRACE = _FRESH_EMPTY_SESSION_GRACE _FRESH_SESSION_GRACE = _FRESH_EMPTY_SESSION_GRACE
@@ -44,15 +54,14 @@ def is_session_recently_active(row, now=None, grace=_FRESH_SESSION_GRACE) -> boo
async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bool = True) -> str: async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bool = True) -> str:
"""Remove empty/Incognito sessions and optionally organize saved chats. """Run session cleanup + (optional) AI folder sort for the given owner.
Args: Args:
owner: user whose sessions to process owner: user whose sessions to process
skip_llm: when True, do only Phase 1 (delete empty/Incognito sessions); skip_llm: when True, do only Phase 1 (delete empty/throwaway sessions);
skip Phase 2 (AI folder assignment). Used by the built-in daily skip Phase 2 (AI folder assignment). Used by the built-in daily
background sweep so it never burns LLM tokens. background sweep so it never burns LLM tokens.
delete_throwaway: retained for caller compatibility. Ordinary sessions delete_throwaway: when False, only empty/incognito sessions are deleted.
with saved messages are always preserved, regardless of this flag.
Returns a human-readable summary of what was done. Returns a human-readable summary of what was done.
""" """
@@ -62,7 +71,7 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
db = SessionLocal() db = SessionLocal()
try: try:
# ── Phase 1: Delete only empty and explicitly Incognito sessions ── # ── Phase 1: Delete empty/throwaway sessions ──
deleted_empty = 0 deleted_empty = 0
deleted_throwaway = 0 deleted_throwaway = 0
@@ -84,13 +93,47 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
if is_session_recently_active(row, now=cleanup_now): if is_session_recently_active(row, now=cleanup_now):
continue continue
has_messages = db.query(DbMsg.id).filter( msg_count = db.query(DbMsg.id).filter(
DbMsg.session_id == row.id DbMsg.session_id == row.id
).first() is not None ).limit(_THROWAWAY_MAX_MESSAGES + 1).count()
if not has_messages: should_delete = False
if msg_count == 0:
if is_fresh: if is_fresh:
continue continue
should_delete = True
deleted_empty += 1 deleted_empty += 1
elif delete_throwaway and msg_count <= _THROWAWAY_MAX_MESSAGES:
name = (row.name or "").strip().lower()
first_msg = db.query(DbMsg.content).filter(
DbMsg.session_id == row.id, DbMsg.role == "user"
).order_by(DbMsg.timestamp).first()
first_text = (first_msg[0] or "").strip().lower() if first_msg else ""
assistant_count = db.query(DbMsg.id).filter(
DbMsg.session_id == row.id, DbMsg.role == "assistant"
).limit(1).count()
if name in _THROWAWAY_NAMES or name.startswith("chat:") or first_text in _THROWAWAY_NAMES:
should_delete = True
deleted_throwaway += 1
elif msg_count == 1 and assistant_count == 0:
should_delete = True
deleted_throwaway += 1
elif msg_count <= 4 and first_text and len(first_text.split()) <= 8 and len(first_text) <= 80:
# Short trivial chats — e.g. "write hi to a friend" → "Hi!"
should_delete = True
deleted_throwaway += 1
else:
# Aggressive: total message text under 250 chars combined = trivial
msg_rows = db.query(DbMsg.content).filter(
DbMsg.session_id == row.id
).all()
total_chars = sum(len(m[0] or "") for m in msg_rows)
if total_chars <= 250:
should_delete = True
deleted_throwaway += 1
if should_delete:
db.delete(row) db.delete(row)
if deleted_empty or deleted_throwaway: if deleted_empty or deleted_throwaway:
+2 -22
View File
@@ -132,23 +132,6 @@ async def _cached(key: Tuple, ttl: float, fetch: Callable[[], Awaitable[Any]]) -
_shared_cache_pending.pop(key, None) _shared_cache_pending.pop(key, None)
def is_valid_cron(cron_expression: str) -> bool:
"""True for a standard 5-field cron expression that croniter can parse.
croniter also accepts 6- and 7-field expressions with a seconds field, but
the scheduler works at one-minute resolution and would run such a task
every second.
"""
if len((cron_expression or "").split()) != 5:
return False
try:
from croniter import croniter
croniter(cron_expression)
except Exception:
return False
return True
def compute_next_run(schedule: str, scheduled_time: str, def compute_next_run(schedule: str, scheduled_time: str,
scheduled_day: int = None, scheduled_day: int = None,
scheduled_date: datetime = None, scheduled_date: datetime = None,
@@ -192,9 +175,6 @@ def compute_next_run(schedule: str, scheduled_time: str,
return dt.astimezone(timezone.utc).replace(tzinfo=None) return dt.astimezone(timezone.utc).replace(tzinfo=None)
if schedule == "cron" and cron_expression: if schedule == "cron" and cron_expression:
if not is_valid_cron(cron_expression):
logger.warning(f"Invalid cron expression '{cron_expression}'")
return None
try: try:
from croniter import croniter from croniter import croniter
cron = croniter(cron_expression, now) cron = croniter(cron_expression, now)
@@ -326,7 +306,7 @@ def _digest_windows(now):
def _checkin_calendar_events(db, owner, start, end): def _checkin_calendar_events(db, owner, start, end):
"""Calendar events in [start, end) for ONE owner, for the check-in digest. """Calendar events in [start, end] for ONE owner, for the check-in digest.
Ownership lives on CalendarCal.owner; events inherit it via calendar_id. Ownership lives on CalendarCal.owner; events inherit it via calendar_id.
The digest query had no owner scope, so it pulled EVERY user's events into The digest query had no owner scope, so it pulled EVERY user's events into
@@ -340,7 +320,7 @@ def _checkin_calendar_events(db, owner, start, end):
.filter( .filter(
_CC.owner == owner, _CC.owner == owner,
_CE.dtstart >= start, _CE.dtstart >= start,
_CE.dtstart < end, _CE.dtstart <= end,
_CE.status != "cancelled", _CE.status != "cancelled",
) )
.order_by(_CE.dtstart) .order_by(_CE.dtstart)
-1
View File
@@ -41,7 +41,6 @@ _REQUIRED_NATIVE_TOOL_ARGS = {
"write_file": ("path",), "write_file": ("path",),
"edit_file": ("path",), "edit_file": ("path",),
"apply_patch": ("patch_text", "patchText", "patch"), "apply_patch": ("patch_text", "patchText", "patch"),
"generate_image": ("prompt",),
"host_shell": ("command",), "host_shell": ("command",),
} }
+1 -3
View File
@@ -4592,9 +4592,7 @@ body.bg-pattern-ascii-fireflies {
width:100%; background:var(--bg); color:var(--fg); width:100%; background:var(--bg); color:var(--fg);
border:1px solid var(--border); border-radius:6px; border:1px solid var(--border); border-radius:6px;
padding:8px; font-family:inherit; font-size:0.95rem; padding:8px; font-family:inherit; font-size:0.95rem;
resize:both; outline:none; resize:vertical; outline:none;
field-sizing:content;
max-width:100%; box-sizing:border-box;
min-height:80px; min-height:80px;
} }
.edit-textarea:focus { border-color:var(--red); } .edit-textarea:focus { border-color:var(--red); }
+1 -7
View File
@@ -771,13 +771,7 @@ function _getModal() {
<button class="cal-side-nav cal-side-nav-next" id="cal-side-next" title="Next" aria-label="Next">&rsaquo;</button> <button class="cal-side-nav cal-side-nav-next" id="cal-side-next" title="Next" aria-label="Next">&rsaquo;</button>
</div>`; </div>`;
document.body.appendChild(_modal); document.body.appendChild(_modal);
_modal.querySelector('#cal-close').addEventListener('click', () => { _modal.querySelector('#cal-close').addEventListener('click', closeCalendar);
if (_modal.querySelector('.cal-form')) {
_render();
return;
}
closeCalendar();
});
_modal.querySelector('#cal-side-prev')?.addEventListener('click', (e) => { _modal.querySelector('#cal-side-prev')?.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
-65
View File
@@ -1,65 +0,0 @@
function streamError(payload) {
const detail = payload?.data ?? payload;
const candidates = [detail?.message, detail?.text, detail?.error?.message,
detail?.error, payload?.message, payload?.text, payload?.error?.message,
payload?.error, typeof payload === 'string' ? payload : null];
const message = candidates.find(value => typeof value === 'string' && value.trim());
return new Error(message || 'Model request failed.');
}
/** Decode complete SSE frames, requiring the backend's terminal DONE event. */
export async function consumeCompareEvents(response, onEvent, onActivity) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let line = '', event = 'message', data = [], skipLF = false;
try {
while (true) {
const { done, value } = await reader.read();
if (!done) onActivity?.();
let chunk = decoder.decode(value, { stream: !done });
if (skipLF && chunk.length) {
if (chunk.startsWith('\n')) chunk = chunk.slice(1);
skipLF = false;
}
const separators = /\r\n|\r|\n/g;
let start = 0, separator;
while ((separator = separators.exec(chunk))) {
line += chunk.slice(start, separator.index);
start = separator.index + separator[0].length;
skipLF = separator[0] === '\r' && start === chunk.length;
if (!line) {
if (data.length || event === 'error') {
const raw = data.join('\n');
if (event !== 'error' && raw === '[DONE]') return;
let payload;
try { payload = JSON.parse(raw); }
catch (_) {
if (event === 'error') throw streamError(raw);
throw new Error('Invalid model stream data.');
}
if (event === 'error' || payload?.type === 'error') throw streamError(payload);
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('Invalid model stream data.');
}
await onEvent(payload);
}
event = 'message';
data = [];
} else if (!line.startsWith(':')) {
const colon = line.indexOf(':');
const field = colon < 0 ? line : line.slice(0, colon);
let value = colon < 0 ? '' : line.slice(colon + 1);
if (value.startsWith(' ')) value = value.slice(1);
if (field === 'data') data.push(value);
else if (field === 'event') event = value || 'message';
}
line = '';
}
line += chunk.slice(start);
if (done) throw new Error('Stream ended before completion.');
}
} finally {
try { await reader.cancel(); } catch (_) {}
reader.releaseLock();
}
}
+22 -12
View File
@@ -1,6 +1,5 @@
// compare/stream.js — SSE streaming to panes // compare/stream.js — SSE streaming to panes
import state from './state.js'; import state from './state.js';
import { consumeCompareEvents } from './sse.js';
import { addFinishBadge } from './vote.js?v=20260828resendcaldrag1'; import { addFinishBadge } from './vote.js?v=20260828resendcaldrag1';
import { getModelCost, renderAskUserCard, safeDisplayImageSrc } from '../chatRenderer.js?v=20260914metricssummary1'; import { getModelCost, renderAskUserCard, safeDisplayImageSrc } from '../chatRenderer.js?v=20260914metricssummary1';
import markdownModule from '../markdown.js'; import markdownModule from '../markdown.js';
@@ -473,9 +472,25 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
}); });
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
await consumeCompareEvents(response, const reader = response.body.getReader();
(json) => { const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
_resetIdleTimeout(); // any chunk = stream is alive
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') break;
try { try {
const json = JSON.parse(data);
if (json.type === 'metrics') { if (json.type === 'metrics') {
metrics = json.data; metrics = json.data;
@@ -657,7 +672,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
if (hist) hist.scrollTop = hist.scrollHeight; if (hist) hist.scrollTop = hist.scrollHeight;
} else if (json.delta) { } else if (json.delta) {
// Skip text deltas if we already rendered an image // Skip text deltas if we already rendered an image
if (aiMsgEl._imageData) return; if (aiMsgEl._imageData) continue;
// Capture TTFT on very first text delta // Capture TTFT on very first text delta
if (!accumulated && !_ttft) _ttft = performance.now() - _timerStart; if (!accumulated && !_ttft) _ttft = performance.now() - _timerStart;
// On first delta, destroy spinner and prepare text area // On first delta, destroy spinner and prepare text area
@@ -686,7 +701,8 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
_scheduleLiveRender(target); _scheduleLiveRender(target);
} }
} catch (e) { console.warn('Compare stream render error:', e); } } catch (e) { console.warn('Compare stream render error:', e); }
}, _resetIdleTimeout); }
}
streamOk = true; streamOk = true;
// Destroy any remaining spinner // Destroy any remaining spinner
@@ -884,15 +900,9 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
} }
} else { } else {
console.error('Compare stream error:', error); console.error('Compare stream error:', error);
awaitingChoice = false; aiBody.innerHTML = '<span style="color:var(--color-error);">Error: ' + escapeHtml(error.message) + '</span>';
const message = state._blindMode ? 'Model request failed. Try again.' : error.message;
aiBody.innerHTML = '<span style="color:var(--color-error);">Error: ' + escapeHtml(message) + '</span>';
} }
} finally { } finally {
if (aiMsgEl._spinner) {
aiMsgEl._spinner.destroy();
aiMsgEl._spinner = null;
}
clearTimeout(timeoutId); clearTimeout(timeoutId);
_timerDone = true; _timerDone = true;
cancelAnimationFrame(_rafId); cancelAnimationFrame(_rafId);
+5 -6
View File
@@ -1195,12 +1195,11 @@ async function _fetchDependencies() {
// For backends with a recipe catalog (vllm / sglang / llama_cpp), // For backends with a recipe catalog (vllm / sglang / llama_cpp),
// append a caret button that toggles a per-row recipe panel below. // append a caret button that toggles a per-row recipe panel below.
const hasRecipe = RECIPE_BACKENDS.has(pkg.name); const hasRecipe = RECIPE_BACKENDS.has(pkg.name);
// The caret is the only way to open the recipe panel (the Installed ▾ // Standalone recipe-caret button removed — the "Pick install
// menu has no recipe item), and the HW Fit / serve-card deep links // command" action lives inside the Installed ▾ dropdown menu
// open it by clicking this caret (#6747). // (see _showDepMenu) so each row only has ONE caret to click.
const recipeCaret = hasRecipe // Kept the variable so downstream concat code stays the same.
? `<button class="cookbook-dep-tag cookbook-dep-recipe-caret" data-dep-recipe-toggle="${esc(pkg.name)}" title="Pick a model to see the exact install commands" aria-expanded="false" style="background:none;border:1px solid var(--border);padding:2px 6px;display:inline-flex;align-items:center;cursor:pointer;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transition:transform 0.15s"><polyline points="6 9 12 15 18 9"/></svg></button>` const recipeCaret = '';
: '';
const recipePanel = hasRecipe ? _recipePanelHtml(pkg.name) : ''; const recipePanel = hasRecipe ? _recipePanelHtml(pkg.name) : '';
// When llama_cpp (or any future engine) reports build_deps_missing // When llama_cpp (or any future engine) reports build_deps_missing
// from its system_prereqs probe, surface a one-tap install button // from its system_prereqs probe, surface a one-tap install button
+2 -2
View File
@@ -5232,7 +5232,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
} }
async function _handleAttachUpload(e) { async function _handleAttachUpload(e) {
const files = Array.from(e.target.files || []); const files = e.target.files;
e.target.value = ''; // reset for next upload e.target.value = ''; // reset for next upload
await _uploadComposeFiles(files); await _uploadComposeFiles(files);
} }
@@ -5963,7 +5963,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
} }
async function _handleMarkdownImageUpload(e) { async function _handleMarkdownImageUpload(e) {
const files = Array.from(e.target.files || []); const files = e.target.files;
e.target.value = ''; e.target.value = '';
await _uploadMarkdownImages(files); await _uploadMarkdownImages(files);
} }
+1 -8
View File
@@ -2650,14 +2650,7 @@ export function openEmailLibrary(opts = {}) {
} }
// Wire events // Wire events
document.getElementById('email-lib-close').addEventListener('click', () => { document.getElementById('email-lib-close').addEventListener('click', closeEmailLibrary);
const modal = document.getElementById('email-lib-modal');
if (modal?.classList.contains('email-reading') || modal?.querySelector('.doclib-card-expanded')) {
_exitEmailReaderModeForList();
return;
}
closeEmailLibrary();
});
document.getElementById('email-settings-header-back')?.addEventListener('click', _hideEmailSettingsPage); document.getElementById('email-settings-header-back')?.addEventListener('click', _hideEmailSettingsPage);
// Clicking the modal header (anywhere except buttons/inputs) collapses // Clicking the modal header (anywhere except buttons/inputs) collapses
+10 -18
View File
@@ -73,24 +73,16 @@ function _isDangerousSrcset(value) {
// Escape + linkify URLs and email addresses. Returns innerHTML-safe markup. // Escape + linkify URLs and email addresses. Returns innerHTML-safe markup.
export function _escLinkify(text) { export function _escLinkify(text) {
const raw = String(text || ''); const escaped = _esc(text);
// Match raw text once so generated anchors are never linkified again. // URLs: http(s)://... or www....
const tokenRe = /\b((?:https?:\/\/|www\.)[^\s<>"']+[^\s<>"'.,;:!?)\]])|\b([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g; const urlRe = /\b((?:https?:\/\/|www\.)[^\s<>"']+[^\s<>"'.,;:!?)\]])/g;
const parts = []; const mailRe = /\b([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g;
let offset = 0; return escaped
for (const match of raw.matchAll(tokenRe)) { .replace(urlRe, (m) => {
parts.push(_esc(raw.slice(offset, match.index))); const href = m.startsWith('www.') ? `https://${m}` : m;
const token = match[0]; return `<a href="${_attrEsc(href)}" target="_blank" rel="noopener noreferrer">${m}</a>`;
const isUrl = !!match[1]; })
const href = isUrl .replace(mailRe, (m) => `<a href="${_attrEsc(`mailto:${m}`)}">${m}</a>`);
? (token.startsWith('www.') ? `https://${token}` : token)
: `mailto:${token}`;
const attrs = isUrl ? ' target="_blank" rel="noopener noreferrer"' : '';
parts.push(`<a href="${_attrEsc(_esc(href))}"${attrs}>${_esc(token)}</a>`);
offset = match.index + token.length;
}
parts.push(_esc(raw.slice(offset)));
return parts.join('');
} }
// Pull display name out of "Name <email@x>"; fallback to local-part of // Pull display name out of "Name <email@x>"; fallback to local-part of
+2 -28
View File
@@ -5759,20 +5759,7 @@ function _updateBrushCursor(e) {
state.cursorEl.style.height = diameterY + 'px'; state.cursorEl.style.height = diameterY + 'px';
} }
state.cursorEl.classList.toggle('ge-brush-cursor--pixel', pixelBrush); state.cursorEl.classList.toggle('ge-brush-cursor--pixel', pixelBrush);
// Updating an inline style on every move invalidates document-wide :has() state.cursorEl.style.transform = `translate3d(${clientX - diameterX / 2}px, ${clientY - diameterY / 2}px, 0)`;
// selectors. A paused animation keeps the same transform in the rendering
// pipeline without mutating the cursor's style attribute on every frame.
const cursorTransform = `translate3d(${clientX - diameterX / 2}px, ${clientY - diameterY / 2}px, 0)`;
if (!state.cursorEl._positionAnimation || state.cursorEl._positionAnimation.playState === 'idle') {
state.cursorEl._positionAnimation = state.cursorEl.animate(
[{ transform: cursorTransform }, { transform: cursorTransform }],
{ duration: 1, fill: 'both' },
);
state.cursorEl._positionAnimation.pause();
state.cursorEl._positionAnimation.currentTime = 0;
} else {
state.cursorEl._positionAnimation.effect.setKeyframes([{ transform: cursorTransform }, { transform: cursorTransform }]);
}
if (state.cursorEl.style.display !== 'block') { if (state.cursorEl.style.display !== 'block') {
state.cursorEl.style.display = 'block'; state.cursorEl.style.display = 'block';
} }
@@ -14074,20 +14061,7 @@ export function closeEditor(options = {}) {
const h = state.editorDocClickHandlers.pop(); const h = state.editorDocClickHandlers.pop();
try { document.removeEventListener('click', h); } catch {} try { document.removeEventListener('click', h); } catch {}
} }
if (_cursorRaf) { cancelAnimationFrame(_cursorRaf); _cursorRaf = null; } if (state.cursorEl) { state.cursorEl.remove(); state.cursorEl = null; }
_pendingCursorEvent = null;
_lastCursorRect = null;
state._lastCursorDiameterX = null;
state._lastCursorDiameterY = null;
state._lastCursorBorderColor = null;
if (state.cursorEl) {
if (state.cursorEl._positionAnimation) {
try { state.cursorEl._positionAnimation.cancel(); } catch {}
state.cursorEl._positionAnimation = null;
}
state.cursorEl.remove();
state.cursorEl = null;
}
try { document.getElementById('ge-cursor-layer')?.remove(); } catch {} try { document.getElementById('ge-cursor-layer')?.remove(); } catch {}
// Tear down all floating popups + the dock so closing the editor // Tear down all floating popups + the dock so closing the editor
// doesn't leave stale chips/panels behind on top of the gallery. // doesn't leave stale chips/panels behind on top of the gallery.
+2 -4
View File
@@ -801,8 +801,7 @@ async function _sendRoundRobin(msg, box) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [{ body: JSON.stringify({ messages: [{
role: 'user', role: 'user',
content: `[${m._groupName || m.display}]: ${response}`, content: `[${m._groupName || m.display}]: ${response}`
metadata: { group_peer: true, group_model: m._groupName || m.display, model: m.mid }
}]}), }]}),
}); });
} catch (e) { console.warn('[group] sync failed:', e); } } catch (e) { console.warn('[group] sync failed:', e); }
@@ -829,8 +828,7 @@ async function _syncAllResponses(holders) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [{ body: JSON.stringify({ messages: [{
role: 'user', role: 'user',
content: `[${model._groupName || model.display}]: ${response}`, content: `[${model._groupName || model.display}]: ${response}`
metadata: { group_peer: true, group_model: model._groupName || model.display, model: model.mid }
}]}), }]}),
}); });
} catch (e) { /* silent */ } } catch (e) { /* silent */ }
+3 -5
View File
@@ -163,13 +163,11 @@ function buildCategoryChips() {
const container = document.getElementById('memory-category-filters'); const container = document.getElementById('memory-category-filters');
if (!container) return; if (!container) return;
const cats = new Set(memories.map(m => m.category || 'fact'));
if (activeCategory !== 'all' && !cats.has(activeCategory)) activeCategory = 'all';
// Hide the chip row entirely when there are no memories — no point showing // Hide the chip row entirely when there are no memories — no point showing
// an "all" chip with nothing to filter. // an "all" chip with nothing to filter.
if (!memories.length) { container.innerHTML = ''; return; } if (!memories.length) { container.innerHTML = ''; return; }
const cats = new Set(memories.map(m => m.category || 'fact'));
const sorted = ['all', ...Array.from(cats).sort()]; const sorted = ['all', ...Array.from(cats).sort()];
const counts = memories.reduce((result, memory) => { const counts = memories.reduce((result, memory) => {
const category = memory.category || 'fact'; const category = memory.category || 'fact';
@@ -279,7 +277,6 @@ export function openMemoryModal(tabName = 'browse') {
else if (m.default?.loadSkills) m.default.loadSkills(true); else if (m.default?.loadSkills) m.default.loadSkills(true);
}); });
} }
if (mode === 'memory') return loadMemories();
} }
function _decodeMemoryAnchorId(memoryId) { function _decodeMemoryAnchorId(memoryId) {
@@ -300,8 +297,9 @@ function _resetMemoryDeepLinkFilters() {
// Memory search output may expose a shortened id, so prefix matching is // Memory search output may expose a shortened id, so prefix matching is
// intentional here. // intentional here.
export async function openMemory(memoryId, attempt = 0) { export async function openMemory(memoryId, attempt = 0) {
openMemoryModal('browse');
_resetMemoryDeepLinkFilters(); _resetMemoryDeepLinkFilters();
await openMemoryModal('browse'); await loadMemories();
if (renderMemoryList) renderMemoryList(); if (renderMemoryList) renderMemoryList();
const wanted = _decodeMemoryAnchorId(memoryId); const wanted = _decodeMemoryAnchorId(memoryId);
if (!wanted) return; if (!wanted) return;
+2 -13
View File
@@ -299,18 +299,7 @@ function _renderHistoryMessage(msg, modelName) {
} else { } else {
displayContent = ''; displayContent = '';
} }
let displayRole = msg.role; if (msg.role === 'user') {
// Peer turns remain user-role context for the model, but are agent replies
// in history. Only explicit identity plus its exact prefix can opt in.
if (displayRole === 'user' && meta?.group_peer === true
&& typeof meta.group_model === 'string' && meta.group_model) {
const prefix = `[${meta.group_model}]: `;
if (displayContent.startsWith(prefix)) {
displayRole = 'assistant';
displayContent = displayContent.slice(prefix.length);
}
}
if (displayRole === 'user') {
displayContent = _stripUserVisionBlocks(displayContent); displayContent = _stripUserVisionBlocks(displayContent);
const trimmed = displayContent.trim(); const trimmed = displayContent.trim();
if ( if (
@@ -327,7 +316,7 @@ function _renderHistoryMessage(msg, modelName) {
displayContent = `[Doc edit: ${docEditMatch[1]}] ${docEditMatch[3]}`; displayContent = `[Doc edit: ${docEditMatch[1]}] ${docEditMatch[3]}`;
} }
} }
return _addHistoryMessageWithFullRenderer(displayRole, displayContent, modelName, meta); return _addHistoryMessageWithFullRenderer(msg.role, displayContent, modelName, meta);
} }
function _clearHistoryPager() { function _clearHistoryPager() {
-11
View File
@@ -1260,9 +1260,6 @@ async function _expandSkillCard(card, name) {
card._md = md || ''; card._md = md || '';
} else { } else {
pre.textContent = 'Loading…'; pre.textContent = 'Loading…';
const editBtn = [...card.querySelectorAll('.skill-card-preview button')]
.find(button => button.textContent.trim() === 'Edit');
if (editBtn) editBtn.disabled = true;
try { try {
const md = await _fetchSkillMarkdown(name); const md = await _fetchSkillMarkdown(name);
pre.textContent = md || '(empty)'; pre.textContent = md || '(empty)';
@@ -1270,8 +1267,6 @@ async function _expandSkillCard(card, name) {
card._md = md; card._md = md;
} catch (e) { } catch (e) {
pre.textContent = `Failed to load SKILL.md: ${e.message || 'Unknown error'}`; pre.textContent = `Failed to load SKILL.md: ${e.message || 'Unknown error'}`;
} finally {
if (editBtn) editBtn.disabled = !card._mdLoaded;
} }
} }
} }
@@ -1280,9 +1275,6 @@ async function _expandSkillCard(card, name) {
// Swap the read-only <pre> for an editable <textarea> (and back). The // Swap the read-only <pre> for an editable <textarea> (and back). The
// Edit button toggles; a Save button commits via the markdown endpoint. // Edit button toggles; a Save button commits via the markdown endpoint.
function _toggleSkillEdit(card, name) { function _toggleSkillEdit(card, name) {
// A loading/error preview is not source text. Menu entry points must obey
// the same readiness gate as the disabled footer control.
if (!card._mdLoaded) return;
const preview = card.querySelector('.skill-card-preview'); const preview = card.querySelector('.skill-card-preview');
if (!preview) return; if (!preview) return;
const existing = preview.querySelector('.skill-md-editor'); const existing = preview.querySelector('.skill-md-editor');
@@ -1296,9 +1288,6 @@ function _toggleSkillEdit(card, name) {
ta.className = 'skill-md-editor'; ta.className = 'skill-md-editor';
ta.spellcheck = false; ta.spellcheck = false;
ta.value = (card._md != null ? card._md : (pre ? pre.textContent : '')) || ''; ta.value = (card._md != null ? card._md : (pre ? pre.textContent : '')) || '';
// Let the source set the intrinsic height; the card's existing flex/max
// height keeps long files bounded and the Save footer reachable.
ta.rows = Math.max(ta.rows, ta.value.split(/\r\n|\r|\n/).length);
ta.addEventListener('click', (e) => e.stopPropagation()); ta.addEventListener('click', (e) => e.stopPropagation());
if (pre) pre.style.display = 'none'; if (pre) pre.style.display = 'none';
preview.insertBefore(ta, preview.querySelector('.doclib-card-expanded-actions')); preview.insertBefore(ta, preview.querySelector('.doclib-card-expanded-actions'));
+1 -2
View File
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh. // - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached. // - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes. // Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v1115-pr40-editor-precache'; const CACHE_NAME = 'odysseus-v1114-pr40-editor-precache';
// KaTeX resolves these from its own stylesheet, so caching the CSS without them // KaTeX resolves these from its own stylesheet, so caching the CSS without them
// gives offline math fallback glyphs instead of proper typesetting. // gives offline math fallback glyphs instead of proper typesetting.
@@ -38,7 +38,6 @@ const KATEX_FONTS = [
// Both are fetched at install time, in the background. Entries must match the // Both are fetched at install time, in the background. Entries must match the
// exact URL the browser requests, query string included. // exact URL the browser requests, query string included.
const PRECACHE = [ const PRECACHE = [
'/static/js/compare/sse.js',
'/', '/',
'/static/css/00-tokens.css?v=20260929csssplit', '/static/css/00-tokens.css?v=20260929csssplit',
'/static/css/01-agent-chat.css?v=20260929csssplit', '/static/css/01-agent-chat.css?v=20260929csssplit',
-67
View File
@@ -1,67 +0,0 @@
"""Gallery CLI tag filters match both sources and stack like the web library."""
from datetime import datetime, timedelta
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from core.database import Base, GalleryAlbum, GalleryImage
from tests.helpers.cli_loader import load_script
@pytest.fixture
def gallery_cli(monkeypatch, tmp_path):
engine = create_engine(f"sqlite:///{tmp_path / 'gallery.db'}")
Base.metadata.create_all(engine)
sessions = sessionmaker(bind=engine)
with sessions() as db:
db.add(GalleryAlbum(id='sea', name='Sea'))
for index, (uid, tags, ai_tags, favorite, album) in enumerate([
('manual', 'sunset,beach', '', True, 'sea'),
('ai', '', 'Beach,sunset', False, 'sea'),
('split', 'sunset', 'beach', True, None),
('one', 'sunset', '', False, None),
('other', 'forest', 'moss', False, None),
('inactive', 'sunset,beach', 'sunset,beach', True, 'sea'),
]):
db.add(GalleryImage(id=uid, filename=uid+'.png', tags=tags, ai_tags=ai_tags,
favorite=favorite, album_id=album, is_active=uid!='inactive',
created_at=datetime(2026, 1, 1) + timedelta(minutes=index)))
db.commit()
cli = load_script('odysseus-gallery')
monkeypatch.setattr(cli, 'SessionLocal', sessions)
output = []
monkeypatch.setattr(cli, 'emit', lambda rows, args: output.extend(rows))
def run(*argv):
output.clear()
args = cli._build_parser().parse_args(['list', *argv])
args.func(args)
return [row['id'] for row in output]
yield run
engine.dispose()
@pytest.mark.parametrize('tag,expected', [
('sunset', ['one', 'split', 'ai', 'manual']),
('beach,sunset', ['split', 'ai', 'manual']),
('sunset,beach', ['split', 'ai', 'manual']),
(', sunset, , BEACH ,', ['split', 'ai', 'manual']),
('forest', ['other']),
('no-match', []),
(' , ', ['other', 'one', 'split', 'ai', 'manual']),
('sun', ['one', 'split', 'ai', 'manual']),
])
def test_tag_sources_and_stacking(gallery_cli, tag, expected):
assert gallery_cli('--tag', tag) == expected
@pytest.mark.parametrize('options,expected', [
([], ['other', 'one', 'split', 'ai', 'manual']),
(['--tag', 'beach,sunset', '--favorites'], ['split', 'manual']),
(['--tag', 'beach,sunset', '--album', 'Sea'], ['ai', 'manual']),
(['--tag', 'beach', '--limit', '1'], ['split']),
])
def test_tag_filters_preserve_other_list_options(gallery_cli, options, expected):
assert gallery_cli(*options) == expected
-66
View File
@@ -1,66 +0,0 @@
"""The bounded arrival batch is displayed by Date, rather than arrival ID."""
from contextlib import contextmanager
from types import SimpleNamespace
import pytest
from tests.cli.test_mail_cli_read_empty_fetch import _load_mail_cli
@pytest.mark.parametrize('dates,expected', [
(['Wed, 03 Jun 2026 12:00:00 +0000', 'Mon, 01 Jun 2026 09:00:00 +0000'], ['1', '2']),
(['Wed, 03 Jun 2026 12:00:00 +0000', 'Wed, 03 Jun 2026 13:00:00 +0200'], ['1', '2']),
(['Wed, 03 Jun 2026 12:00:00', 'Wed, 03 Jun 2026 11:00:00 +0000'], ['1', '2']),
(['Wed, 03 Jun 2026 12:00:00 +0000', None, 'not a date'], ['1', '3', '2']),
(['Wed, 03 Jun 2026 12:00:00 +0000', 'Wed, 03 Jun 2026 14:00:00 +0200'], ['2', '1']),
(['Wed, 03 Jun 2026 12:00:00 +0000', 'Thu, 04 Jun 2026 12:00:00 +0000'], ['2', '1']),
(['Wed, 31 Dec 1969 12:00:00 +0000', None], ['1', '2']),
])
def test_list_sorts_parsed_dates_without_changing_row_schema(monkeypatch, dates, expected):
cli = _load_mail_cli(monkeypatch)
fetched = []
class Connection:
def select(self, folder, readonly=True):
assert readonly is True
return 'OK', [str(len(dates)).encode()]
def search(self, *_):
return 'OK', [' '.join(str(i+1) for i in range(len(dates))).encode()]
def fetch(self, uid, _):
fetched.append(uid.decode())
date = dates[int(uid)-1]
raw = 'From: Sender <sender@example.com>\r\nSubject: Item '+uid.decode()+'\r\n'
if date is not None:
raw += 'Date: '+date+'\r\n'
return 'OK', [(b'1 (FLAGS (\\Seen) RFC822.HEADER)', (raw+'\r\n').encode())]
@contextmanager
def imap(_):
yield Connection()
rows = []
monkeypatch.setattr(cli, '_imap', imap)
monkeypatch.setattr(cli, 'emit', lambda value, args: rows.extend(value))
cli.cmd_list(SimpleNamespace(account='fixture', folder='INBOX', limit=20))
assert [row['uid'] for row in rows] == expected
assert fetched == [str(i) for i in range(len(dates), 0, -1)]
assert all(set(row) == {'uid', 'date', 'from_addr', 'from_name', 'subject', 'is_read', 'is_answered'} for row in rows)
assert all(row['is_read'] and not row['is_answered'] for row in rows)
def test_list_still_limits_the_arrival_batch(monkeypatch):
cli = _load_mail_cli(monkeypatch)
fetched = []
class Connection:
def select(self, *_args, **_kwargs):return 'OK', []
def search(self, *_):return 'OK', [b'1 2 3']
def fetch(self, uid, _):
fetched.append(uid)
return 'OK', [(b'3 (FLAGS () RFC822.HEADER)', b'From: x@example.com\r\nSubject: bounded\r\n\r\n')]
@contextmanager
def imap(_):yield Connection()
monkeypatch.setattr(cli, '_imap', imap)
monkeypatch.setattr(cli, 'emit', lambda *_:None)
cli.cmd_list(SimpleNamespace(account='fixture', folder='INBOX', limit=1))
assert fetched == [b'3']
-19
View File
@@ -1,19 +0,0 @@
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const marker = '.disposable-ci-fixture.json';
function disposableCIArtifacts() {
if (!(process.env.CI || process.env.GITHUB_ACTIONS) || process.env.ODYSSEUS_E2E_DISPOSABLE_ARTIFACTS !== '1') return false;
const directory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
if (!directory || process.env.PHOTO_EDITOR_E2E_AUTH === '0') return false;
const resolved = path.resolve(directory);
if (!path.basename(resolved).startsWith('odysseus-photo-editor-e2e-')) return false;
if (![os.tmpdir(), '/tmp', '/var/tmp'].some(base => resolved.startsWith(path.resolve(base) + path.sep))) return false;
if (process.env.PHOTO_EDITOR_E2E_DB_PATH !== path.join(resolved, 'app.db')) return false;
try {
const fixture = JSON.parse(fs.readFileSync(path.join(resolved, marker), 'utf8'));
return fixture.synthetic === true && fixture.authenticated === true && fixture.database === path.join(resolved, 'app.db');
} catch { return false; }
}
module.exports = { disposableCIArtifacts, marker };
-477
View File
@@ -46,480 +46,3 @@ test('brush cursor matches the rendered brush diameter and replaces the crosshai
await expect.poll(async () => (await cursor.boundingBox()).width) await expect.poll(async () => (await cursor.boundingBox()).width)
.toBeCloseTo(measurements.expectedWidth * 2, 1); .toBeCloseTo(measurements.expectedWidth * 2, 1);
}); });
test('brush cursor follows the pointer before, during, and after a stroke and cancels animations on close', async ({ page }) => {
await openBlankEditor(page, { width: 400, height: 300 });
await selectTool(page, 'brush');
const canvas = await page.locator('.ge-main-canvas').boundingBox();
const cursor = page.locator('.ge-brush-cursor');
const checkCenter = async (x, y) => {
await expect.poll(async () => {
const box = await cursor.boundingBox();
return box ? Math.max(Math.abs(box.x + box.width / 2 - x), Math.abs(box.y + box.height / 2 - y)) : Infinity;
}).toBeLessThan(1);
};
const a = { x: canvas.x + canvas.width * 0.3, y: canvas.y + canvas.height * 0.4 };
const b = { x: canvas.x + canvas.width * 0.7, y: canvas.y + canvas.height * 0.6 };
await page.mouse.move(a.x, a.y);
await checkCenter(a.x, a.y);
await page.mouse.down();
await page.mouse.move(b.x, b.y, { steps: 8 });
await checkCenter(b.x, b.y);
await page.mouse.up();
await page.mouse.move(a.x, a.y);
await checkCenter(a.x, a.y);
// 1. Ensure at least one animation exists before checking cancellation
// 2. Verify the actual cursor-position animation is paused while active
const activeAnimation = await page.evaluate(() => {
const cursorEl = document.querySelector('.ge-brush-cursor');
const anims = cursorEl ? cursorEl.getAnimations() : [];
return {
count: anims.length,
hasPositionAnim: Boolean(cursorEl?._positionAnimation),
positionAnimPaused: cursorEl?._positionAnimation?.playState === 'paused',
positionAnimCurrentTime: cursorEl?._positionAnimation?.currentTime,
allPaused: anims.length > 0 && anims.every(a => a.playState === 'paused'),
};
});
expect(activeAnimation.count).toBeGreaterThanOrEqual(1);
expect(activeAnimation.hasPositionAnim).toBe(true);
expect(activeAnimation.positionAnimPaused).toBe(true);
expect(activeAnimation.positionAnimCurrentTime).toBe(0);
expect(activeAnimation.allPaused).toBe(true);
// 3. Verify it is canceled when the editor closes
// 4. Ensure temporary test-only references are cleaned up
const closeOutcome = await page.evaluate(async () => {
const cursorEl = document.querySelector('.ge-brush-cursor');
const anims = cursorEl ? cursorEl.getAnimations() : [];
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.closeEditor({ force: true });
return {
trackedCount: anims.length,
allIdle: anims.length > 0 && anims.every(a => a.playState === 'idle'),
states: anims.map(a => a.playState),
};
});
await expect(cursor).toHaveCount(0);
expect(closeOutcome.trackedCount).toBeGreaterThanOrEqual(1);
expect(closeOutcome.allIdle).toBe(true);
expect(closeOutcome.states.every(s => s === 'idle')).toBe(true);
// Verify no temporary references linger on window
const windowClean = await page.evaluate(() => {
return !('__cursorAnimationsForTest' in window) && !('__styleTransformMutations' in window);
});
expect(windowClean).toBe(true);
});
test('steady pointer movement does not perform repeated inline style-transform writes', async ({ page }) => {
await openBlankEditor(page, { width: 400, height: 300 });
await selectTool(page, 'brush');
const canvas = await page.locator('.ge-main-canvas').boundingBox();
const cursor = page.locator('.ge-brush-cursor');
// Warm up the cursor first: move pointer onto canvas
const startX = canvas.x + canvas.width * 0.25;
const startY = canvas.y + canvas.height * 0.25;
await page.mouse.move(startX, startY);
await expect(cursor).toBeVisible();
// Observe style attribute mutations during steady pointer movement.
// Brush size, color, and mode remain unchanged throughout.
await page.evaluate(() => {
const el = document.querySelector('.ge-brush-cursor');
window.__styleMutationRecords = [];
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'attributes' && m.attributeName === 'style') {
const oldStyle = m.oldValue || '';
const currentStyle = el.getAttribute('style') || '';
// Track mutations where style-transform was written inline
if (el.style.transform || currentStyle.includes('transform:') || oldStyle.includes('transform:')) {
window.__styleMutationRecords.push({
oldStyle,
currentStyle,
inlineTransform: el.style.transform,
});
}
}
}
});
observer.observe(el, { attributes: true, attributeFilter: ['style'], attributeOldValue: true });
window.__styleMutationObserver = observer;
});
// Preserve actual mouse interactions: steady movement across 10 steps
const endX = canvas.x + canvas.width * 0.75;
const endY = canvas.y + canvas.height * 0.75;
await page.mouse.move(endX, endY, { steps: 10 });
// Confirm the cursor accurately tracked the pointer
await expect.poll(async () => {
const box = await cursor.boundingBox();
return box ? Math.max(Math.abs(box.x + box.width / 2 - endX), Math.abs(box.y + box.height / 2 - endY)) : Infinity;
}).toBeLessThan(1);
// Collect observation results and clean up observer and globals
const { mutationCount, inlineTransform, animCount } = await page.evaluate(() => {
window.__styleMutationObserver?.disconnect();
delete window.__styleMutationObserver;
const records = window.__styleMutationRecords || [];
delete window.__styleMutationRecords;
const el = document.querySelector('.ge-brush-cursor');
return {
mutationCount: records.length,
inlineTransform: el?.style.transform || '',
animCount: el?.getAnimations?.().length || 0,
};
});
// The optimized implementation uses paused Web Animations keyframes, avoiding repeated
// inline style-transform writes that trigger expensive :has() document invalidations.
expect(mutationCount).toBe(0);
expect(inlineTransform).toBe('');
expect(animCount).toBeGreaterThanOrEqual(1);
});
test('cross-tool cursor positioning, sizing, and styling across brush, eraser, pixel, lasso, inpaint, liquify, and clone', async ({ page }) => {
await openBlankEditor(page, { width: 400, height: 300 });
const canvas = await page.locator('.ge-main-canvas').boundingBox();
const cursor = page.locator('.ge-brush-cursor');
const checkCenter = async (x, y) => {
await expect.poll(async () => {
const box = await cursor.boundingBox();
return box ? Math.max(Math.abs(box.x + box.width / 2 - x), Math.abs(box.y + box.height / 2 - y)) : Infinity;
}).toBeLessThan(1);
};
const midX = canvas.x + canvas.width / 2;
const midY = canvas.y + canvas.height / 2;
// 1. Brush tool
await selectTool(page, 'brush');
await page.mouse.move(midX, midY);
await expect(cursor).toBeVisible();
await checkCenter(midX, midY);
let props = await page.evaluate(() => {
const el = document.querySelector('.ge-brush-cursor');
return {
isPixel: el.classList.contains('ge-brush-cursor--pixel'),
display: el.style.display,
borderStyle: el.style.borderStyle,
};
});
expect(props.isPixel).toBe(false);
expect(props.display).toBe('block');
// 2. Eraser tool
await selectTool(page, 'eraser');
await page.mouse.move(midX + 10, midY + 10);
await checkCenter(midX + 10, midY + 10);
props = await page.evaluate(() => {
const el = document.querySelector('.ge-brush-cursor');
return {
borderColor: el.style.borderColor,
isPixel: el.classList.contains('ge-brush-cursor--pixel'),
};
});
expect(props.borderColor).toBe('rgba(255, 255, 255, 0.6)');
expect(props.isPixel).toBe(false);
// 3. Pixel brush (via tool / preset)
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.tool = 'pixel';
state.brushPixel = true;
state.brushSize = 16;
});
await page.mouse.move(midX - 10, midY - 10);
await checkCenter(midX - 10, midY - 10);
props = await page.evaluate(() => {
const el = document.querySelector('.ge-brush-cursor');
return {
isPixel: el.classList.contains('ge-brush-cursor--pixel'),
width: parseFloat(el.style.width),
};
});
expect(props.isPixel).toBe(true);
// 4. Lasso tool
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.tool = 'lasso';
state.brushPixel = false;
});
await page.mouse.move(midX + 20, midY - 10);
await checkCenter(midX + 20, midY - 10);
props = await page.evaluate(() => {
const el = document.querySelector('.ge-brush-cursor');
return {
borderColor: el.style.borderColor,
background: el.style.background,
};
});
expect(props.borderColor).toBe('rgba(255, 255, 255, 0.85)');
expect(props.background).toBe('rgba(0, 0, 0, 0.15)');
// 5. Inpaint tool (paint mode and erase mode)
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.tool = 'inpaint';
state.inpaintEraseMode = false;
});
await page.mouse.move(midX, midY);
await checkCenter(midX, midY);
props = await page.evaluate(() => {
const el = document.querySelector('.ge-brush-cursor');
return {
borderColor: el.style.borderColor,
background: el.style.background,
borderStyle: el.style.borderStyle,
};
});
expect(props.borderColor).toBe('rgba(255, 80, 80, 0.8)');
expect(props.borderStyle).toBe('solid');
// Inpaint erase mode
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.inpaintEraseMode = true;
});
await page.mouse.move(midX + 5, midY + 5);
props = await page.evaluate(() => {
const el = document.querySelector('.ge-brush-cursor');
return {
borderColor: el.style.borderColor,
borderStyle: el.style.borderStyle,
};
});
expect(props.borderColor).toBe('rgba(255, 255, 255, 0.9)');
expect(props.borderStyle).toBe('dashed');
// 6. Liquify tool
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.tool = 'liquify';
state.liquifySize = 100;
});
await page.mouse.move(midX - 15, midY + 15);
await checkCenter(midX - 15, midY + 15);
props = await page.evaluate(() => {
const el = document.querySelector('.ge-brush-cursor');
return {
width: parseFloat(el.style.width),
height: parseFloat(el.style.height),
display: el.style.display,
};
});
expect(props.display).toBe('block');
expect(props.width).toBeGreaterThan(0);
// 7. Clone tool
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.tool = 'clone';
state.brushSize = 50;
});
await page.mouse.move(midX, midY);
await checkCenter(midX, midY);
await expect(cursor).toBeVisible();
// Clean close
await page.evaluate(async () => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.closeEditor({ force: true });
});
await expect(cursor).toHaveCount(0);
});
test('canvas boundaries, pointer exit and re-entry, and viewport resize', async ({ page }) => {
await openBlankEditor(page, { width: 400, height: 300 });
await selectTool(page, 'brush');
const canvas = await page.locator('.ge-main-canvas').boundingBox();
const cursor = page.locator('.ge-brush-cursor');
const checkCenter = async (x, y) => {
await expect.poll(async () => {
const box = await cursor.boundingBox();
return box ? Math.max(Math.abs(box.x + box.width / 2 - x), Math.abs(box.y + box.height / 2 - y)) : Infinity;
}).toBeLessThan(1);
};
// 1. Enter canvas
const insideX = canvas.x + canvas.width * 0.5;
const insideY = canvas.y + canvas.height * 0.5;
await page.mouse.move(insideX, insideY);
await expect(cursor).toBeVisible();
await checkCenter(insideX, insideY);
// 2. Exit canvas boundary: cursor should hide
const outsideX = canvas.x - 50;
const outsideY = canvas.y - 50;
await page.mouse.move(outsideX, outsideY);
await expect(cursor).toBeHidden();
// 3. Re-enter canvas: cursor should restore visibility and position
await page.mouse.move(insideX, insideY);
await expect(cursor).toBeVisible();
await checkCenter(insideX, insideY);
// 4. Viewport resize
await page.setViewportSize({ width: 1280, height: 800 });
await page.waitForTimeout(100);
const newCanvas = await page.locator('.ge-main-canvas').boundingBox();
const newInsideX = newCanvas.x + newCanvas.width * 0.5;
const newInsideY = newCanvas.y + newCanvas.height * 0.5;
await page.mouse.move(newInsideX, newInsideY);
await expect(cursor).toBeVisible();
await checkCenter(newInsideX, newInsideY);
// Clean close
await page.evaluate(async () => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.closeEditor({ force: true });
});
await expect(cursor).toHaveCount(0);
});
for (const view of ['2x zoom', 'fit-to-view']) {
test(`zoom and fit-to-view cursor scaling and positioning: ${view}`, async ({ page }) => {
const size = view === '2x zoom' ? { width: 400, height: 300 } : { width: 1600, height: 1200 };
await openBlankEditor(page, size);
await selectTool(page, 'brush');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 50;
// The zoom-in control applies 1.25x through the real zoom handler.
state.zoom = 1.6;
state.zoomViewMode = 'scale';
});
await page.locator('#ge-zoom-in').click();
await expect.poll(() => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { zoom: state.zoom, mode: state.zoomViewMode };
})).toEqual({ zoom: 2, mode: 'scale' });
if (view === 'fit-to-view') {
await page.locator('#ge-zoom-100').click();
const fitted = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const area = document.querySelector('.ge-canvas-area');
const pad = state._footerDocked ? 0 : 20;
const rulerPad = state.rulersVisible ? 18 : 0;
return {
zoom: state.zoom,
mode: state.zoomViewMode,
expectedZoom: Math.min(1,
(area.clientWidth - 2 * pad - rulerPad) / state.imgWidth,
(area.clientHeight - 2 * pad - rulerPad) / state.imgHeight),
};
});
expect(fitted.mode).toBe('fit');
expect(fitted.zoom).toBeGreaterThan(0);
expect(fitted.zoom).toBeLessThan(1);
expect(fitted.zoom).toBeCloseTo(fitted.expectedZoom, 6);
}
const canvas = page.locator('.ge-main-canvas');
const canvasBox = await canvas.boundingBox();
const cursor = page.locator('.ge-brush-cursor');
if (view === '2x zoom') {
expect(canvasBox.width).toBeCloseTo(size.width * 2, 1);
expect(canvasBox.height).toBeCloseTo(size.height * 2, 1);
}
const targetX = canvasBox.x + canvasBox.width * 0.4;
const targetY = canvasBox.y + canvasBox.height * 0.4;
await page.mouse.move(targetX, targetY);
await expect(cursor).toBeVisible();
// Pointer coordinates can be rounded to device pixels; keep the existing
// subpixel alignment tolerance separate from the stricter diameter check.
await expect.poll(async () => {
const box = await cursor.boundingBox();
return box ? Math.max(
Math.abs(box.x + box.width / 2 - targetX),
Math.abs(box.y + box.height / 2 - targetY),
) : Infinity;
}).toBeLessThan(1);
await expect.poll(() => page.evaluate(() => {
const canvasEl = document.querySelector('.ge-main-canvas');
const canvasRect = canvasEl.getBoundingClientRect();
const cursorRect = document.querySelector('.ge-brush-cursor').getBoundingClientRect();
return Math.max(
Math.abs(cursorRect.width - 50 * canvasRect.width / canvasEl.width),
Math.abs(cursorRect.height - 50 * canvasRect.height / canvasEl.height),
);
})).toBeLessThan(0.1);
await page.evaluate(async () => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.closeEditor({ force: true });
});
await expect(cursor).toHaveCount(0);
});
}
test('memory and lifecycle: repeated editor open/close cycles retain no detached cursors or active animations', async ({ page }) => {
for (let cycle = 0; cycle < 5; cycle++) {
await openBlankEditor(page, { width: 400, height: 300 });
await selectTool(page, 'brush');
const canvas = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.move(canvas.x + canvas.width * 0.5, canvas.y + canvas.height * 0.5);
const cursor = page.locator('.ge-brush-cursor');
await expect(cursor).toBeVisible();
// Verify cursor layer and animation are active
const cycleState = await page.evaluate(() => {
const el = document.querySelector('.ge-brush-cursor');
window.__lastCycleAnim = el?._positionAnimation;
return {
cursorCount: document.querySelectorAll('.ge-brush-cursor').length,
layerCount: document.querySelectorAll('#ge-cursor-layer').length,
hasAnim: Boolean(el?._positionAnimation),
animPaused: el?._positionAnimation?.playState === 'paused',
};
});
expect(cycleState.cursorCount).toBe(1);
expect(cycleState.layerCount).toBe(1);
expect(cycleState.animPaused).toBe(true);
// Close editor
await page.evaluate(async () => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.closeEditor({ force: true });
});
// Verify complete cleanup after close
await expect(cursor).toHaveCount(0);
const postClose = await page.evaluate(() => {
const anim = window.__lastCycleAnim;
delete window.__lastCycleAnim;
return {
cursorCount: document.querySelectorAll('.ge-brush-cursor').length,
layerCount: document.querySelectorAll('#ge-cursor-layer').length,
animIdle: anim ? anim.playState === 'idle' : true,
activeAnims: document.getAnimations().filter(a => {
const target = a.effect?.target;
return target && (target.classList?.contains('ge-brush-cursor') || target.id === 'ge-cursor-layer');
}).length,
};
});
expect(postClose.cursorCount).toBe(0);
expect(postClose.layerCount).toBe(0);
expect(postClose.animIdle).toBe(true);
expect(postClose.activeAnims).toBe(0);
}
});
+2 -29
View File
@@ -35,7 +35,7 @@ test('brush presets and shared stroke controls remain reusable', async ({ page }
await expect(page.locator('#ge-brush-preset option', { hasText: 'My Detail Brush' })).toHaveCount(0); await expect(page.locator('#ge-brush-preset option', { hasText: 'My Detail Brush' })).toHaveCount(0);
}); });
test('long sampled strokes stay responsive and undo atomically', async ({ page, browserName }, testInfo) => { test('long sampled strokes stay responsive and undo atomically', async ({ page }) => {
await openBlankEditor(page); await openBlankEditor(page);
await selectTool(page, 'brush'); await selectTool(page, 'brush');
await page.locator('.ge-size-slider').fill('700'); await page.locator('.ge-size-slider').fill('700');
@@ -49,40 +49,13 @@ test('long sampled strokes stay responsive and undo atomically', async ({ page,
const before = await flattenedPixelDigest(page); const before = await flattenedPixelDigest(page);
const start = await canvasPoint(page, 0.08, 0.5); const start = await canvasPoint(page, 0.08, 0.5);
const end = await canvasPoint(page, 0.92, 0.5); const end = await canvasPoint(page, 0.92, 0.5);
// Playwright delivers stepped moves at frame cadence: 240 events already
// cost ~4s on an inert page. Measure that transport cost with the same input,
// and separately bound real Chromium main-thread work at the original5s.
const control = await page.context().newPage();
let controlDuration;
try {
await control.setContent('<canvas style="position:fixed;inset:0;width:100vw;height:100vh"></canvas>');
const started = Date.now();
await control.mouse.move(start.x, start.y);
await control.mouse.down();
await control.mouse.move(end.x, end.y, { steps: 240 });
await control.mouse.up();
controlDuration = Date.now() - started;
} finally {
await control.close();
await page.bringToFront();
}
const metrics = browserName === 'chromium' ? await page.context().newCDPSession(page) : null;
if (metrics) await metrics.send('Performance.enable');
const taskTime = async () => (await metrics.send('Performance.getMetrics')).metrics.find(item => item.name === 'TaskDuration').value *1000;
const beforeTask = metrics ? await taskTime() : null;
const startedAt = Date.now(); const startedAt = Date.now();
await page.mouse.move(start.x, start.y); await page.mouse.move(start.x, start.y);
await page.mouse.down(); await page.mouse.down();
await page.mouse.move(end.x, end.y, { steps: 240 }); await page.mouse.move(end.x, end.y, { steps: 240 });
await page.mouse.up(); await page.mouse.up();
const strokeDuration = Date.now() - startedAt; const strokeDuration = Date.now() - startedAt;
const mainThreadDuration = metrics ? (await taskTime()) - beforeTask : null; expect(strokeDuration).toBeLessThan(5000);
if (metrics) await metrics.detach();
await testInfo.attach('stroke-timing', { body: Buffer.from(JSON.stringify({
samples: 240, strokeDuration, controlDuration, mainThreadDuration,
})), contentType: 'application/json' });
expect(strokeDuration - controlDuration).toBeLessThan(5000);
if (mainThreadDuration !== null) expect(mainThreadDuration).toBeLessThan(5000);
const after = await flattenedPixelDigest(page); const after = await flattenedPixelDigest(page);
expect(after).not.toEqual(before); expect(after).not.toEqual(before);
await expect(thumb).toHaveAttribute('width', '68'); await expect(thumb).toHaveAttribute('width', '68');
@@ -1,21 +0,0 @@
const { test, expect } = require('@playwright/test');
const { spawnSync } = require('node:child_process');
const path = require('node:path');
// The previously opt-in node:test now uses only this config's owned app/account.
// Its native JUnit report retains its authored identity; this case is a driver.
test('native email reply regression on the disposable authenticated app', async ({ context, baseURL }, testInfo) => {
const origin = new URL(baseURL);
expect(origin.hostname).toBe('127.0.0.1');
const cookie = (await context.cookies(baseURL)).find(item => item.name === 'odysseus_session');
expect(cookie?.value).toBeTruthy();
const report = testInfo.outputPath('native-email-junit.xml');
const result = spawnSync(process.execPath, ['--test', '--test-reporter=spec', '--test-reporter-destination=stdout',
'--test-reporter=junit', `--test-reporter-destination=${report}`, 'tests/emailReplyBrowser.test.mjs'], {
cwd: path.resolve(__dirname, '../../..'), encoding: 'utf8', timeout: 80_000,
env: { ...process.env, ODYSSEUS_UI_TEST_URL: baseURL, ODYSSEUS_UI_TEST_COOKIE: cookie.value },
});
console.log(result.stdout);
expect(result.error, String(result.error)).toBeUndefined();
expect(result.status, result.stdout + result.stderr).toBe(0);
});
+3 -33
View File
@@ -2,12 +2,6 @@ const fs = require('node:fs');
const os = require('node:os'); const os = require('node:os');
const path = require('node:path'); const path = require('node:path');
const { defineConfig } = require('@playwright/test'); const { defineConfig } = require('@playwright/test');
const { disposableCIArtifacts, marker } = require('./artifact-policy');
const isCI = Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS);
const disposableOptIn = isCI && process.env.ODYSSEUS_E2E_DISPOSABLE_ARTIFACTS === '1';
if (disposableOptIn && (process.env.PHOTO_EDITOR_E2E_DATA_DIR || process.env.PHOTO_EDITOR_E2E_DB_PATH || process.env.PHOTO_EDITOR_E2E_AUTH === '0') && !disposableCIArtifacts()) {
throw new Error('CI artifact opt-in requires fresh authenticated fixture paths');
}
const port = Number(process.env.PHOTO_EDITOR_E2E_PORT || 7013); const port = Number(process.env.PHOTO_EDITOR_E2E_PORT || 7013);
const baseURL = `http://127.0.0.1:${port}`; const baseURL = `http://127.0.0.1:${port}`;
@@ -30,25 +24,7 @@ if (!['chromium', 'firefox', 'webkit'].includes(browserName)) {
throw new Error(`Unsupported PHOTO_EDITOR_E2E_BROWSER: ${browserName}`); throw new Error(`Unsupported PHOTO_EDITOR_E2E_BROWSER: ${browserName}`);
} }
if (disposableOptIn) { const isCI = Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS);
const fixturePayload = JSON.stringify({ synthetic: true, authenticated: true, database: databasePath });
fs.writeFileSync(path.join(dataDirectory, marker), fixturePayload);
const resultsDir = path.join(repositoryRoot, 'test-results');
if (!fs.existsSync(resultsDir)) {
try { fs.mkdirSync(resultsDir, { recursive: true }); } catch (_) {}
}
try {
fs.writeFileSync(path.join(resultsDir, marker), fixturePayload);
} catch (_) {}
if (process.env.GITHUB_ENV) {
try {
fs.appendFileSync(
process.env.GITHUB_ENV,
`PHOTO_EDITOR_E2E_DATA_DIR=${dataDirectory}\nPHOTO_EDITOR_E2E_DB_PATH=${databasePath}\n`
);
} catch (_) {}
}
}
const traceOptIn = !isCI && ['1', 'true'].includes(String(process.env.PLAYWRIGHT_ENABLE_TRACE || '').toLowerCase()); const traceOptIn = !isCI && ['1', 'true'].includes(String(process.env.PLAYWRIGHT_ENABLE_TRACE || '').toLowerCase());
module.exports = defineConfig({ module.exports = defineConfig({
@@ -56,8 +32,6 @@ module.exports = defineConfig({
outputDir: path.join(__dirname, '..', '..', 'test-results', 'photo-editor'), outputDir: path.join(__dirname, '..', '..', 'test-results', 'photo-editor'),
timeout: 90_000, timeout: 90_000,
expect: { timeout: 10_000 }, expect: { timeout: 10_000 },
forbidOnly: isCI,
retries: 0,
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 1,
reporter: isCI ? 'line' : 'line', reporter: isCI ? 'line' : 'line',
@@ -75,9 +49,9 @@ module.exports = defineConfig({
serviceWorkers: 'block', serviceWorkers: 'block',
...(authenticated ? { storageState: path.join(dataDirectory, 'browser-auth.json') } : {}), ...(authenticated ? { storageState: path.join(dataDirectory, 'browser-auth.json') } : {}),
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
// Default CI suppression remains; only the fresh synthetic fixture opts in. // Unconditional CI trace suppression: Playwright traces are NEVER recorded in CI.
// Local developer tracing requires explicit PLAYWRIGHT_ENABLE_TRACE=1. // Local developer tracing requires explicit PLAYWRIGHT_ENABLE_TRACE=1.
trace: (traceOptIn || disposableCIArtifacts()) ? 'retain-on-failure' : 'off', trace: traceOptIn ? 'retain-on-failure' : 'off',
}, },
webServer: { webServer: {
command: `${JSON.stringify(python)} -m uvicorn app:app --host 127.0.0.1 --port ${port}`, command: `${JSON.stringify(python)} -m uvicorn app:app --host 127.0.0.1 --port ${port}`,
@@ -91,10 +65,6 @@ module.exports = defineConfig({
DATABASE_URL: `sqlite:///${databasePath}`, DATABASE_URL: `sqlite:///${databasePath}`,
ODYSSEUS_DATA_DIR: dataDirectory, ODYSSEUS_DATA_DIR: dataDirectory,
ODYSSEUS_STARTUP_WARMUPS: '0', ODYSSEUS_STARTUP_WARMUPS: '0',
ODYSSEUS_TOOL_INDEX_PREWARM: '0',
ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE: '1',
npm_config_offline: 'true',
npm_config_cache: path.join(dataDirectory, 'npm-cache'),
RESPONSE_COMPRESSION_ENABLED: 'false', RESPONSE_COMPRESSION_ENABLED: 'false',
}, },
}, },
+2 -16
View File
@@ -2,7 +2,6 @@ const fs = require('node:fs');
const os = require('node:os'); const os = require('node:os');
const path = require('node:path'); const path = require('node:path');
const { execFileSync } = require('node:child_process'); const { execFileSync } = require('node:child_process');
const { disposableCIArtifacts, marker } = require('./artifact-policy');
const repoRoot = path.resolve(__dirname, '..', '..'); const repoRoot = path.resolve(__dirname, '..', '..');
const defaultResultsDir = path.join(repoRoot, 'test-results'); const defaultResultsDir = path.join(repoRoot, 'test-results');
@@ -30,9 +29,6 @@ module.exports = async (options = {}) => {
const isPlaywrightConfig = options && (options.configFile || options.rootDir); const isPlaywrightConfig = options && (options.configFile || options.rootDir);
const testOpts = (!isPlaywrightConfig && typeof options === 'object') ? options : {}; const testOpts = (!isPlaywrightConfig && typeof options === 'object') ? options : {};
// Validate fixture ownership before removing its temporary directory.
const disposableCI = disposableCIArtifacts();
// Confine cleanup of PHOTO_EDITOR_E2E_DATA_DIR: // Confine cleanup of PHOTO_EDITOR_E2E_DATA_DIR:
// Strictly require it to be within an OS temporary directory and match the expected test prefix // Strictly require it to be within an OS temporary directory and match the expected test prefix
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR; const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
@@ -104,7 +100,7 @@ module.exports = async (options = {}) => {
const isCI = Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS); const isCI = Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS);
const traceOptIn = !isCI && ['1', 'true'].includes(String(process.env.PLAYWRIGHT_ENABLE_TRACE || '').toLowerCase()); const traceOptIn = !isCI && ['1', 'true'].includes(String(process.env.PLAYWRIGHT_ENABLE_TRACE || '').toLowerCase());
if ((isCI && !disposableCI) || (!traceOptIn && !disposableCI)) { if (isCI || !traceOptIn) {
// In CI or default local runs, purge all sensitive trace archives and artifacts // In CI or default local runs, purge all sensitive trace archives and artifacts
purgeSensitiveArtifacts(resultsDir); purgeSensitiveArtifacts(resultsDir);
} else { } else {
@@ -128,17 +124,7 @@ module.exports = async (options = {}) => {
if (!sanitizerSucceeded) { if (!sanitizerSucceeded) {
// Fail closed: purge all remaining zip or tmp archives // Fail closed: purge all remaining zip or tmp archives
if (disposableCI) purgeSensitiveArtifacts(resultsDir); purgeZipAndTmp(resultsDir);
else purgeZipAndTmp(resultsDir);
} else if (disposableCI) {
// Re-affirm fixture proof in resultsDir so independent verifier can establish ownership
try {
fs.writeFileSync(path.join(resultsDir, marker), JSON.stringify({
synthetic: true,
authenticated: true,
database: process.env.PHOTO_EDITOR_E2E_DB_PATH || path.join(dataDirectory || '', 'app.db'),
}));
} catch (_) {}
} }
} }
} }
-71
View File
@@ -1,71 +0,0 @@
{
"native-ci": [
"tests/backgroundToolJobs.test.mjs",
"tests/chatEditorProgress.test.mjs",
"tests/chatImageDeletion.test.mjs",
"tests/chatPhotoEditorIntegrity.test.mjs",
"tests/chatProcessingHandoff.test.mjs",
"tests/copyChatHeader.test.mjs",
"tests/emailReplyStream.test.mjs",
"tests/galleryModelPickerSync.test.mjs",
"tests/generatedImageResult.test.mjs",
"tests/researchMobileButtons.test.mjs",
"tests/schemaThinkingProbe.test.mjs",
"tests/sidebarNewChat.test.mjs",
"tests/skillsApproval.test.mjs",
"tests/streamingRendererRaf.test.mjs",
"tests/toolFollowupOracle.test.mjs",
"tests/tool_followup_oracle.test.mjs"
],
"browser-ci": [
"tests/emailReplyBrowser.test.mjs"
],
"python-ci": {
"tests/historyResumeRendering.test.mjs": [
"tests/test_history_resume_rendering_js.py",
"tests/test_live_fallback_round_attribution.py"
],
"tests/live_thinking_scheduler.test.mjs": [
"tests/test_live_thinking_scheduler_js.py"
],
"tests/noteTestOracle.test.mjs": [
"tests/test_notes_reference_contracts_js.py"
],
"tests/notesApplication.test.mjs": [
"tests/test_notes_reference_contracts_js.py"
],
"tests/notesCharacterization.test.mjs": [
"tests/test_notes_reference_contracts_js.py"
],
"tests/notesDraftAutosave.test.mjs": [
"tests/test_notes_reference_contracts_js.py"
],
"tests/notesInfrastructure.test.mjs": [
"tests/test_notes_reference_contracts_js.py"
],
"tests/notesRecurrence.test.mjs": [
"tests/test_notes_reference_contracts_js.py"
],
"tests/notesReminders.test.mjs": [
"tests/test_notes_reference_contracts_js.py"
],
"tests/notesWorkflows.test.mjs": [
"tests/test_notes_reference_contracts_js.py"
],
"tests/selectedNotesContext.test.mjs": [
"tests/test_notes_reference_contracts_js.py"
],
"tests/streaming/invariant.test.mjs": [
"tests/test_streaming_segmenter_js.py"
],
"tests/streaming/segmenter.test.mjs": [
"tests/test_streaming_segmenter_js.py"
],
"tests/turnRendering.test.mjs": [
"tests/test_turn_rendering_js.py"
]
},
"architecture-ci": [
"tests/frontendArchitecture.test.mjs"
]
}
+12 -40
View File
@@ -13,7 +13,6 @@ import socketserver
import threading import threading
import pytest import pytest
import requests
from src import caldav_sync, caldav_writeback from src import caldav_sync, caldav_writeback
@@ -27,13 +26,12 @@ def test_build_dav_client_disables_redirects():
def test_dav_client_does_not_follow_redirect_to_internal_host(): def test_dav_client_does_not_follow_redirect_to_internal_host():
"""End-to-end through the real DAVClient: a 302 toward an internal host """End-to-end through the real DAVClient: a 302 toward an internal host
must NOT be followed. Exercise the session limit separately because some must NOT be followed. Without the fix the sink is contacted (SSRF); with it
caldav versions independently refuse cross-origin PROPFIND redirects.""" the redirect is refused and the sink is never reached."""
pytest.importorskip("caldav") pytest.importorskip("caldav")
sink_hits: list[str] = [] sink_hits: list[str] = []
public_methods: list[str] = [] public_methods: list[str] = []
same_origin_hits: list[str] = []
class _Internal(http.server.BaseHTTPRequestHandler): class _Internal(http.server.BaseHTTPRequestHandler):
# Stand-in for an internal service the attacker redirects toward. # Stand-in for an internal service the attacker redirects toward.
@@ -50,15 +48,9 @@ def test_dav_client_does_not_follow_redirect_to_internal_host():
class _Public(http.server.BaseHTTPRequestHandler): class _Public(http.server.BaseHTTPRequestHandler):
# The "validated" public CalDAV server that redirects everything inward. # The "validated" public CalDAV server that redirects everything inward.
def do_GET(self): # noqa: N802 def do_GET(self): # noqa: N802
if self.path == "/landing":
same_origin_hits.append(self.path)
self.send_response(200)
self.end_headers()
return
public_methods.append(self.command) public_methods.append(self.command)
self.send_response(302) self.send_response(302)
location = "/landing" if self.path == "/same" else f"http://127.0.0.1:{internal_port}/leak" self.send_header("Location", f"http://127.0.0.1:{internal_port}/leak")
self.send_header("Location", location)
self.end_headers() self.end_headers()
do_PROPFIND = do_GET do_PROPFIND = do_GET
@@ -70,10 +62,8 @@ def test_dav_client_does_not_follow_redirect_to_internal_host():
internal_port = internal.server_address[1] internal_port = internal.server_address[1]
public = socketserver.TCPServer(("127.0.0.1", 0), _Public) public = socketserver.TCPServer(("127.0.0.1", 0), _Public)
public_port = public.server_address[1] public_port = public.server_address[1]
internal_thread = threading.Thread(target=internal.serve_forever, daemon=True) threading.Thread(target=internal.serve_forever, daemon=True).start()
public_thread = threading.Thread(target=public.serve_forever, daemon=True) threading.Thread(target=public.serve_forever, daemon=True).start()
internal_thread.start()
public_thread.start()
try: try:
public_url = f"http://127.0.0.1:{public_port}/dav" public_url = f"http://127.0.0.1:{public_port}/dav"
client = caldav_sync._build_dav_client(public_url, "u", "p") client = caldav_sync._build_dav_client(public_url, "u", "p")
@@ -92,35 +82,17 @@ def test_dav_client_does_not_follow_redirect_to_internal_host():
assert public_methods in (["PROPFIND"], ["PROPFIND", "GET"]), public_methods assert public_methods in (["PROPFIND"], ["PROPFIND", "GET"]), public_methods
assert sink_hits == [], "redirect toward an internal host must not be followed" assert sink_hits == [], "redirect toward an internal host must not be followed"
# GET redirects are handled by the HTTP session even in caldav 3.7. # Positive control: demonstrate that the sink would see the redirect
# This makes the cross-port SSRF assertion sensitive to our own limit. # if the session's redirect protection were removed.
with pytest.raises(Exception, match="Exceeded 0 redirects"):
client.request(public_url, "GET", "")
assert sink_hits == [], "GET redirect toward the internal sink must be blocked"
# requests can reach the cross-port sink when redirects are enabled,
# even if DAVClient independently rejects cross-origin PROPFIND redirects.
with requests.Session() as session:
response = session.get(public_url, timeout=5)
assert response.status_code == 207
assert sink_hits == ["/leak"], "the cross-port redirect must reach the sink"
# GET follows redirects at the HTTP session layer across these caldav
# versions. Check the application's limit on a same-origin redirect too.
same_url = f"http://127.0.0.1:{public_port}/same"
with pytest.raises(Exception, match="Exceeded 0 redirects"):
client.request(same_url, "GET", "")
assert same_origin_hits == []
client.session.max_redirects = 30 client.session.max_redirects = 30
client.request(same_url, "GET", "") try:
assert same_origin_hits == ["/landing"] client.request(public_url, "PROPFIND", "")
except Exception:
pass # The sink's response is intentionally not a valid DAV body.
assert sink_hits == ["/leak"], "the test must detect a followed redirect"
finally: finally:
internal.shutdown() internal.shutdown()
public.shutdown() public.shutdown()
internal_thread.join()
public_thread.join()
internal.server_close()
public.server_close()
def test_sync_and_writeback_construct_clients_through_the_helper(): def test_sync_and_writeback_construct_clients_through_the_helper():
-35
View File
@@ -1,35 +0,0 @@
"""The CalDAV config routes answer 400 for a body that is JSON but not an object."""
import asyncio
import json
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from routes.calendar_routes import _json_object_body
def _request(raw: bytes):
async def _json():
return json.loads(raw)
return SimpleNamespace(json=_json)
def _parse(raw: bytes):
return asyncio.run(_json_object_body(_request(raw)))
def test_object_body_is_returned():
assert _parse(b'{"url": "https://dav.example/"}') == {"url": "https://dav.example/"}
@pytest.mark.parametrize("raw", [b"", b"not json"])
def test_unreadable_body_counts_as_empty(raw):
assert _parse(raw) == {}
@pytest.mark.parametrize("raw", [b"[]", b'"x"', b"5", b"null"])
def test_non_object_body_is_a_400(raw):
with pytest.raises(HTTPException) as exc:
_parse(raw)
assert exc.value.status_code == 400
@@ -1,148 +0,0 @@
"""Both chat tidy entry points retain saved conversations and still remove empties."""
import asyncio
from datetime import datetime, timedelta, timezone
import pytest
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
import core.database as database
import core.session_manager as manager_module
import routes.session_routes as routes
import src.session_actions as actions
@pytest.fixture
def tidy_api(tmp_path, monkeypatch):
engine = create_engine(
f"sqlite:///{(tmp_path / 'tidy.db').as_posix()}",
connect_args={"check_same_thread": False},
)
database.Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine, autoflush=False)
monkeypatch.setattr(database, "SessionLocal", factory)
monkeypatch.setattr(manager_module, "SessionLocal", factory)
monkeypatch.setattr(routes, "SessionLocal", factory)
monkeypatch.setattr(routes, "router", APIRouter(prefix="/api"))
monkeypatch.setattr(routes, "effective_user", lambda request: "alice")
manager = manager_module.SessionManager.__new__(manager_module.SessionManager)
manager.sessions = {}
manager.upload_handler = None
monkeypatch.setattr(manager, "get_sessions_for_user", lambda owner: {})
app = FastAPI()
app.include_router(routes.setup_session_routes(manager, {}))
with TestClient(app) as client:
yield client, factory, manager
engine.dispose()
def run_tidy(client, entrypoint):
if entrypoint == "action":
return asyncio.run(actions.run_auto_sort("alice", skip_llm=True))
response = client.post("/api/sessions/auto-sort?skip_llm=true")
assert response.status_code == 200, response.text
return response.json()
@pytest.mark.parametrize("entrypoint", ["route", "action"])
@pytest.mark.parametrize("title,prompt", [
("New chat", "Please analyze the following contract and retain all details. " * 20),
("Contract analysis", "Review contract"),
("Chat: analysis", "Explain these results and retain the full answer"),
("Test", "Here is a large input that should be analyzed and retained"),
], ids=["generic-title", "short-prompt", "title-prefix", "test-title"])
def test_tidy_preserves_nontrivial_saved_answer(tidy_api, title, prompt, entrypoint):
client, factory, manager = tidy_api
old = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=2)
answer = "Clause analysis and recommended changes.\n" * 2000
with factory() as db:
db.add(database.Session(
id="valuable-chat", name=title, owner="alice", endpoint_url="", model="",
archived=False, is_important=False, message_count=2,
created_at=old, updated_at=old, last_accessed=old, last_message_at=old,
))
db.commit()
db.add_all([
database.ChatMessage(id="prompt", session_id="valuable-chat", role="user",
content=prompt, timestamp=old),
database.ChatMessage(id="answer", session_id="valuable-chat", role="assistant",
content=answer, timestamp=old + timedelta(seconds=1)),
])
db.commit()
run_tidy(client, entrypoint)
with factory() as db:
remaining = db.query(database.ChatMessage).filter_by(session_id="valuable-chat").count()
survives = db.get(database.Session, "valuable-chat") is not None
assert survives and remaining == 2, "Title/prompt heuristics must not erase saved answers"
with factory() as db:
assert db.get(database.ChatMessage, "answer").content == answer
@pytest.mark.parametrize("entrypoint", ["route", "action"])
@pytest.mark.parametrize("kind", ["unanswered", "greeting", "empty", "fresh-empty", "starred-empty", "archived", "other-owner", "incognito"])
def test_tidy_cleanup_boundaries(tidy_api, entrypoint, kind):
client, factory, manager = tidy_api
now = datetime.now(timezone.utc).replace(tzinfo=None)
timestamp = now if kind == "fresh-empty" else now - timedelta(hours=2)
message = kind in {"unanswered", "greeting", "archived", "other-owner", "incognito"}
with factory() as db:
db.add(database.Session(
id="boundary-chat", name="Incognito" if kind == "incognito" else "New chat",
owner="bob" if kind == "other-owner" else "alice", endpoint_url="", model="",
archived=kind == "archived", is_important=kind == "starred-empty",
# Deliberately inaccurate metadata: cleanup must use message rows.
message_count=0 if message else 40,
created_at=timestamp, updated_at=timestamp, last_accessed=timestamp, last_message_at=timestamp,
))
db.commit()
if message:
db.add(database.ChatMessage(id="boundary-message", session_id="boundary-chat", role="user",
content="hi" if kind == "greeting" else "Pending question " * 100,
timestamp=timestamp))
db.commit()
run_tidy(client, entrypoint)
deleted = kind in {"empty", "incognito"}
with factory() as db:
assert (db.get(database.Session, "boundary-chat") is None) == deleted
assert db.query(database.ChatMessage).filter_by(session_id="boundary-chat").count() == int(message and not deleted)
@pytest.mark.parametrize("entrypoint", ["route", "action"])
def test_tidy_keeps_folder_assignment_working(tidy_api, monkeypatch, entrypoint):
from types import SimpleNamespace
import src.llm_core as llm
import src.task_endpoint as endpoints
client, factory, manager = tidy_api
old = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=2)
for sid in ["chat-one-long-id", "chat-two-long-id"]:
with factory() as db:
db.add(database.Session(id=sid, name="New chat", owner="alice", endpoint_url="", model="",
archived=False, created_at=old, updated_at=old,
last_accessed=old, last_message_at=old))
db.commit()
db.add(database.ChatMessage(id=f"{sid}-message", session_id=sid, role="user",
content="Saved question", timestamp=old))
db.commit()
manager.sessions[sid] = SimpleNamespace(id=sid, name="New chat", archived=False,
updated_at=old.isoformat(), created_at=old.isoformat())
monkeypatch.setattr(manager, "get_sessions_for_user", lambda owner: manager.sessions)
monkeypatch.setattr(endpoints, "resolve_task_endpoint", lambda *args, **kwargs: ("http://model.invalid", "test", {}))
response_text = '{"folders":{"Projects":["chat-one","chat-two"]}}'
monkeypatch.setattr(llm, "llm_call", lambda *args, **kwargs: response_text)
async def async_response(*args, **kwargs):
return response_text
monkeypatch.setattr(llm, "llm_call_async", async_response)
if entrypoint == "action":
result = asyncio.run(actions.run_auto_sort("alice"))
assert "Sorted 2 sessions" in result
else:
response = client.post("/api/sessions/auto-sort")
assert response.status_code == 200, response.text
assert response.json()["updated"] == 2
with factory() as db:
assert db.query(database.ChatMessage).count() == 2
assert {row.folder for row in db.query(database.Session).all()} == {"Projects"}
+2 -43
View File
@@ -7,13 +7,13 @@ CalendarCal.owner; the query must join it, like routes/calendar_routes.
""" """
import uuid import uuid
import sys import sys
from datetime import datetime, timedelta from datetime import datetime
import pytest import pytest
from tests.helpers.database import disposable_database from tests.helpers.database import disposable_database
from core.database import CalendarEvent, CalendarCal from core.database import CalendarEvent, CalendarCal
from src.task_scheduler import _checkin_calendar_events, _digest_windows from src.task_scheduler import _checkin_calendar_events
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -68,44 +68,3 @@ def test_cancelled_excluded_and_window_respected():
assert "cancelled" not in [ev.summary for ev in out] assert "cancelled" not in [ev.summary for ev in out]
finally: finally:
db.close() db.close()
@pytest.mark.parametrize("days, expected_label", [(2, "this_week"), (7, "next_30_days")])
def test_boundary_event_appears_in_only_one_digest_window(days, expected_label):
now = datetime(2026, 6, 1, 9)
boundary = now + timedelta(days=days)
with _TS() as db:
db.add(CalendarCal(id="boundary-cal", owner="alice", name="Boundary"))
db.add(CalendarEvent(
uid="boundary-event", calendar_id="boundary-cal", summary="Boundary meeting",
dtstart=boundary, dtend=boundary + timedelta(hours=1), status="confirmed",
))
db.commit()
buckets = [
label for label, start, end in _digest_windows(now)
if _checkin_calendar_events(db, "alice", start, end)
]
assert buckets == [expected_label]
def test_digest_includes_start_and_excludes_end():
start = datetime(2026, 6, 1, 9)
end = start + timedelta(days=2)
with _TS() as db:
db.add(CalendarCal(id="bounds-cal", owner="alice", name="Bounds"))
for uid, when in [
("before", start - timedelta(seconds=1)),
("start", start),
("inside", end - timedelta(seconds=1)),
("end", end),
]:
db.add(CalendarEvent(
uid=uid, calendar_id="bounds-cal", summary=uid, dtstart=when,
dtend=when + timedelta(hours=1), status="confirmed",
))
db.commit()
assert [ev.uid for ev in _checkin_calendar_events(db, "alice", start, end)] == [
"start", "inside",
]
@@ -1,70 +0,0 @@
"""Compare's actual stream consumer must preserve evidence and terminal state."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
CASES = [
("lf", 'data: {"delta":"Hello"}\n\ndata: [DONE]\n\n', False, True, "Hello"),
("crlf", 'data:{"delta":"Héllö"}\r\n\r\ndata:[DONE]\r\n\r\n', False, True, "Héllö"),
("multiline", 'data: {"delta":\ndata: "Hello"}\n\ndata: [DONE]\n\n', False, True, "Hello"),
("eof", 'data: {"delta":"Partial"}\n\n', False, False, "ended before completion"),
("named-error", 'event: error\ndata: {"text":"Provider Z private-model failed"}\n\n', False, False, "Provider Z private-model failed"),
("typed-error", 'data: {"type":"error","data":{"message":"Provider Z <img src=x onerror=alert(1)>"}}\n\ndata: [DONE]\n\n', False, False, "Provider Z <img"),
("blind-error", 'event:error\ndata:{"message":"Provider Z private-model failed"}\n\n', True, False, "request failed"),
("empty-error", 'event:error\ndata:\n\n', False, False, "request failed"),
("done-open", 'data: {"delta":"Hello"}\n\ndata: [DONE]\n\n', False, True, "Hello"),
("heartbeat", 'data: {"delta":"Hello"}\n\ndata: [DONE]\n\n', False, True, "Hello"),
("cr", 'data: {"delta":"Hello"}\r\rdata:[DONE]\r\r', False, True, "Hello"),
("invalid", 'data: not-json\n\ndata: [DONE]\n\n', False, False, "Invalid model stream data"),
]
@pytest.mark.parametrize("kind,wire,blind,success,text", CASES)
def test_compare_stream_contract(kind, wire, blind, success, text):
if not shutil.which("node"):
pytest.skip("node not installed")
script = r"""
const assert=require('node:assert/strict');
let chromium;try{({chromium}=require('playwright'));}catch(e){if(e.code==='MODULE_NOT_FOUND')process.exit(77);throw e;}
(async()=>{
const browser=await chromium.launch({headless:true});
try{
const page=await browser.newPage();
await page.goto(process.env.ODYSSEUS_TEST_STATIC_ORIGIN+'/static/js/storage.js');
await page.setContent('<div class="compare-pane" data-pane="0"><div id="cmp-badge-0"></div><div id="cmp-history-0"><div class="msg msg-ai"><div class="body"></div></div></div></div>');
const result=await page.evaluate(async(data)=>{
const {default:state}=await import('/static/js/compare/state.js');
const {streamToPane}=await import('/static/js/compare/stream.js');
Object.assign(state,{isActive:true,_selectedModels:[{model:'fixture',name:'Fixture'}],_paneSessionIds:['session'],_compareMode:'chat',_parallel:true,_blindMode:data.blind,_timeout:data.kind==='heartbeat'?1:5});
const nativeFetch=window.fetch,bytes=new TextEncoder().encode(data.wire);let index=0,cancelled=false,context=null;
window.fetch=async(url,options)=>{
if(!String(url).endsWith('/api/chat_stream'))return nativeFetch(url,options);
context=options.body.get('search_context');
const heartbeats=[...Array(6).fill(': ping\n\n'),data.wire];
return new Response(new ReadableStream({start(controller){options.signal.addEventListener('abort',()=>controller.error(new DOMException('Stopped','AbortError')),{once:true});},async pull(controller){if(data.kind==='heartbeat'){if(index<heartbeats.length){await new Promise(r=>setTimeout(r,200));controller.enqueue(new TextEncoder().encode(heartbeats[index++]));}else controller.close();}else if(index<bytes.length)controller.enqueue(bytes.slice(index,index+=1));else if(data.kind!=='done-open')controller.close();},cancel(){cancelled=true;}}),{headers:{'Content-Type':'text/event-stream'}});
};
const message=document.querySelector('.msg-ai'),spinner=document.createElement('span');let spinnerDestroyed=false;spinner.textContent='Loading';message.querySelector('.body').appendChild(spinner);message._spinner={element:spinner,destroy(){spinnerDestroyed=true;spinner.remove();}};
await Promise.race([streamToPane(0,'session','Prompt',message,{skipBadge:true}),new Promise((_,reject)=>setTimeout(()=>reject(Error('Consumer did not finish at DONE')),data.kind==='heartbeat'?6000:1500))]);
const pane=document.querySelector('.compare-pane');
return {done:pane.classList.contains('is-done'),failed:pane.classList.contains('is-failed'),text:pane.innerText,images:pane.querySelectorAll('img').length,cancelled,spinnerDestroyed,spinnerOwned:!!message._spinner};
},JSON.parse(process.argv[1]));
const expected=JSON.parse(process.argv[1]);
assert.equal(result.done,expected.success);assert.equal(result.failed,!expected.success);assert(result.text.includes(expected.text),JSON.stringify(result));assert.equal(result.images,0);
assert(result.spinnerDestroyed);assert(!result.spinnerOwned);
if(expected.blind)assert(!/Provider Z|private-model/.test(result.text),JSON.stringify(result));
if(expected.kind==='done-open')assert(result.cancelled);
console.log(JSON.stringify(result));
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});
"""
data = dict(kind=kind, wire=wire, blind=blind, success=success, text=text)
result = subprocess.run(["node", "-e", script, json.dumps(data)], cwd=ROOT,
capture_output=True, text=True, timeout=30)
if result.returncode == 77:
pytest.skip("playwright not installed")
assert result.returncode == 0, result.stdout + result.stderr
@@ -1,38 +0,0 @@
"""Guard the only entry point to the Cookbook → Dependencies recipe panel.
The recipe panel (model picker, Pip/Docker toggle, commands and Run) is
rendered hidden, and only the `[data-dep-recipe-toggle]` caret shows it.
The HW Fit and serve-card deep links (`openCookbookDependencies(..., {
expandRecipe })`) open it by clicking that same caret. When the caret was
dropped from the row markup, nothing could open the panel (#6747).
"""
import re
from pathlib import Path
JS_DIR = Path(__file__).resolve().parent.parent / "static" / "js"
COOKBOOK_JS = JS_DIR / "cookbook.js"
DIAGNOSIS_JS = JS_DIR / "cookbook-diagnosis.js"
def _row_renderer(source: str) -> str:
start = source.index("const hasRecipe = RECIPE_BACKENDS.has(pkg.name);")
end = source.index("+ recipePanel;", start)
return source[start:end]
def test_recipe_rows_render_the_panel_toggle():
row = _row_renderer(COOKBOOK_JS.read_text(encoding="utf-8"))
caret = re.search(r"const recipeCaret = hasRecipe\s*\?\s*`([^`]*)`\s*:\s*'';", row)
assert caret, "recipe rows must render a caret, or the recipe panel cannot be opened"
assert 'data-dep-recipe-toggle="${esc(pkg.name)}"' in caret.group(1)
assert 'aria-expanded="false"' in caret.group(1)
assert "+ recipeCaret" in row
def test_toggle_handler_and_deep_link_use_the_rendered_attribute():
cookbook = COOKBOOK_JS.read_text(encoding="utf-8")
diagnosis = DIAGNOSIS_JS.read_text(encoding="utf-8")
assert "list.querySelectorAll('[data-dep-recipe-toggle]')" in cookbook
assert "row.querySelector('[data-dep-recipe-toggle]')" in diagnosis
@@ -1,83 +0,0 @@
"""A completed blob's empty retry marker must not keep its model stalled."""
import json
import os
import subprocess
import sys
import pytest
from routes.cookbook_helpers import _cached_model_scan_script
from routes.cookbook_output import HF_CACHE_COMPLETE_PROBE, HF_CACHE_INCOMPLETE_PROBE
_REPO = "fixture/stale-marker"
_STATES = ["complete", "stale", "empty-unpaired", "partial", "partial-paired", "empty-paired-empty"]
@pytest.fixture(params=["custom", "hf-home", "hub-env"])
def cache(request, tmp_path):
root = tmp_path / "cache with spaces"
model = root / "hub" / ("models--" + _REPO.replace("/", "--"))
blobs = model / "blobs"
blobs.mkdir(parents=True)
snapshot = model / "snapshots" / "revision"
snapshot.mkdir(parents=True)
(snapshot / "config.json").write_text("{}")
env = {key: value for key, value in os.environ.items()
if key not in {"HF_HOME", "HUGGINGFACE_HUB_CACHE", "HF_HUB_CACHE"}}
if request.param == "custom":
argument = str(root)
scan = _cached_model_scan_script(add_hf_cache=str(root / "hub"))
else:
argument = ""
env["HF_HOME" if request.param == "hf-home" else "HUGGINGFACE_HUB_CACHE"] = str(
root if request.param == "hf-home" else root / "hub"
)
scan = _cached_model_scan_script()
return blobs, argument, env, scan
def _seed(blobs, state):
if state in {"complete", "stale", "partial-paired"}:
(blobs / "sha").write_bytes(b"completed model data")
elif state == "empty-paired-empty":
(blobs / "sha").touch()
if state != "complete":
(blobs / "sha.incomplete").write_bytes(b"partial" if state.startswith("partial") else b"")
@pytest.mark.parametrize("state", _STATES)
@pytest.mark.parametrize("probe", [HF_CACHE_COMPLETE_PROBE, HF_CACHE_INCOMPLETE_PROBE], ids=["complete", "incomplete"])
def test_cache_status_probes_distinguish_stale_and_partial(cache, state, probe):
blobs, argument, env, _ = cache
_seed(blobs, state)
before = {path.name: path.read_bytes() for path in blobs.iterdir()}
result = subprocess.run([sys.executable, "-c", probe, _REPO, argument],
env=env, capture_output=True, text=True, timeout=15)
complete = state in {"complete", "stale"}
expected = complete if probe == HF_CACHE_COMPLETE_PROBE else not complete
assert result.returncode == (0 if expected else 1), result.stderr
assert {path.name: path.read_bytes() for path in blobs.iterdir()} == before
@pytest.mark.parametrize("state", _STATES)
def test_generated_inventory_agrees_without_mutating_cache(cache, state):
blobs, _, env, scan = cache
_seed(blobs, state)
before = {path.name: path.read_bytes() for path in blobs.iterdir()}
# Inventory's unrelated Ollama discovery stays off the network. Execute
# the actual generated scanner, including its cache-root resolution.
harness = (
"import urllib.request,shutil\n"
"def offline(*args,**kwargs): raise OSError('Ollama outside this fixture')\n"
"urllib.request.urlopen=offline\nshutil.which=lambda name: None\n"
+ scan
)
result = subprocess.run([sys.executable, "-c", harness], env=env,
capture_output=True, text=True, timeout=15)
assert result.returncode == 0, result.stderr
row = next(item for item in json.loads(result.stdout) if item["repo_id"] == _REPO)
assert row["has_incomplete"] == (state not in {"complete", "stale"})
if state in {"complete", "stale"}:
assert row["nb_files"] == 1
assert row["size_bytes"] == len(b"completed model data")
assert {path.name: path.read_bytes() for path in blobs.iterdir()} == before
-47
View File
@@ -1,47 +0,0 @@
"""POST /api/documents/export-zip answers 400 for ids that are not a list of document ids."""
import json
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
from tests.helpers.import_state import clear_fake_database_modules
clear_fake_database_modules()
import routes.document_routes as droutes
class _Req:
def __init__(self, body):
self.state = SimpleNamespace(current_user="tester")
self._body = body
async def json(self):
return self._body
def _export_zip():
router = droutes.setup_document_routes(MagicMock(), None)
for r in router.routes:
if getattr(r, "path", None) == "/api/documents/export-zip":
return r.endpoint
raise RuntimeError("export-zip route not found")
@pytest.mark.parametrize("body", [[], "x", 5, {}, {"ids": []}, {"ids": None}])
async def test_missing_ids_is_a_400(body):
with pytest.raises(HTTPException) as exc:
await _export_zip()(_Req(body))
assert exc.value.status_code == 400
assert exc.value.detail == "No documents specified"
@pytest.mark.parametrize("ids", [5, "abc", {"a": 1}, [1, 2], [["a"]], ["a", None]])
async def test_ids_that_are_not_a_list_of_strings_is_a_400(ids):
with pytest.raises(HTTPException) as exc:
await _export_zip()(_Req({"ids": ids}))
assert exc.value.status_code == 400
assert exc.value.detail == "ids must be a list of document ids"
@@ -93,7 +93,7 @@ def test_custom_filter_reuses_convolve_kernel():
assert "convolve(readSource(snap), k, divisor, bias, 1)" in EFFECTS assert "convolve(readSource(snap), k, divisor, bias, 1)" in EFFECTS
def test_blur_and_custom_filter_aliases_registered(): def test_new_filter_aliases_registered():
assert "'box-blur': 'blur-box'" in EDITOR assert "'box-blur': 'blur-box'" in EDITOR
assert "'surface-blur': 'blur-surface'" in EDITOR assert "'surface-blur': 'blur-surface'" in EDITOR
assert "custom: 'other-custom'" in EDITOR assert "custom: 'other-custom'" in EDITOR
-53
View File
@@ -1,7 +1,6 @@
"""DOM-XSS regressions for email plain-text linkification helpers.""" """DOM-XSS regressions for email plain-text linkification helpers."""
import json import json
import os
import shutil import shutil
import subprocess import subprocess
import textwrap import textwrap
@@ -101,55 +100,3 @@ def test_email_html_sanitizer_runs_to_fixpoint():
assert "for (let i = 0; i < 4; i++)" in source assert "for (let i = 0; i < 4; i++)" in source
assert "const next = _sanitizeHtmlOnce(out);" in source assert "const next = _sanitizeHtmlOnce(out);" in source
assert "if (next === out) break;" in source assert "if (next === out) break;" in source
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_linkify_preserves_text_and_link_targets_in_a_real_dom():
cases = [
("See https://example.test/contact?email=jane@example.test&next=1 and bob@example.test.",
["https://example.test/contact?email=jane@example.test&next=1", "mailto:bob@example.test"]),
("https://alice@example.test/path", ["https://alice@example.test/path"]),
("www.example.test/?email=jane@example.test", ["https://www.example.test/?email=jane@example.test"]),
("<https://example.test/path> and \"jane@example.test\"",
["https://example.test/path", "mailto:jane@example.test"]),
("(https://example.test/path), jane@example.test!",
["https://example.test/path", "mailto:jane@example.test"]),
("https://example.test/?literal=&quot;&next=1", ["https://example.test/?literal=&quot;&next=1"]),
('<img src=x onerror="alert(1)"> jane@example.test', ["mailto:jane@example.test"]),
("No links\njust <plain> & text", []),
]
script = r'''
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
await page.goto(`${origin}/static/js/documentStats.js`);
const results = await page.evaluate(async (cases) => {
const { _escLinkify } = await import('/static/js/emailLibrary/utils.js');
return cases.map(([raw]) => {
const div = document.createElement('div');
div.innerHTML = _escLinkify(raw);
return {
text: div.textContent,
hrefs: [...div.querySelectorAll('a')].map(a => a.getAttribute('href')),
otherElements: [...div.querySelectorAll('*')].map(e => e.tagName).filter(t => t !== 'A'),
unsafeBlankLink: [...div.querySelectorAll('a[target="_blank"]')]
.some(a => !a.relList.contains('noopener') || !a.relList.contains('noreferrer')),
};
});
}, JSON.parse(process.env.ODYSSEUS_LINKIFY_CASES));
console.log(JSON.stringify(results));
} finally {
await browser.close();
}
'''
result = subprocess.run(
["node", "--input-type=module", "-e", script], cwd=_REPO,
capture_output=True, text=True, timeout=30,
env={**os.environ, "ODYSSEUS_LINKIFY_CASES": json.dumps(cases)},
)
assert result.returncode == 0, result.stderr
results = json.loads(result.stdout)
for (raw, hrefs), actual in zip(cases, results, strict=True):
assert actual == {"text": raw, "hrefs": hrefs, "otherElements": [], "unsafeBlankLink": False}, raw
@@ -1,61 +0,0 @@
"""The native FileList must survive resetting the compose file input."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.helpers.document_source import function_body
@pytest.mark.parametrize("handler,uploader", [("_handleAttachUpload", "_uploadComposeFiles"), ("_handleMarkdownImageUpload", "_uploadMarkdownImages")])
@pytest.mark.parametrize("selections", [[["one.png"]], [["one.png", "two.png"]], [["one.png"], ["one.png"]], [[]]])
def test_compose_file_selection_survives_reset(tmp_path, selections, handler, uploader):
if not shutil.which("node"):
pytest.skip("node not installed")
repo = Path(__file__).resolve().parents[1]
for name, icon in [("one.png", "icon-192.png"), ("two.png", "icon-512.png")]:
shutil.copyfile(repo / "static/icons" / icon, tmp_path / name)
script = r"""
const fs=require('node:fs'),assert=require('node:assert/strict'),crypto=require('node:crypto');
let chromium;try{({chromium}=require('playwright'));}catch(e){if(e.code==='MODULE_NOT_FOUND')process.exit(77);throw e;}
(async()=>{
const browser=await chromium.launch({headless:true});
try{
const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.goto(process.env.ODYSSEUS_TEST_STATIC_ORIGIN+'/static/js/storage.js');
await page.setContent('<input type="file" multiple aria-label="Compose attachments">');
await page.addScriptTag({content:`
window.uploads=[];window.finished=0;
async function __UPLOADER__(files){
for(const file of files){
const hash=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',await file.arrayBuffer()))).map(x=>x.toString(16).padStart(2,'0')).join('');
window.uploads.push({name:file.name,hash});
}
}
__HANDLER__
document.querySelector('input').addEventListener('change',async event=>{
await __HANDLER_NAME__(event);window.finished++;
});
`});
const selections=JSON.parse(process.argv[1]),root=process.argv[2],expected=[];
for(const [index,names] of selections.entries()){
await page.getByLabel('Compose attachments').setInputFiles(names.map(name=>root+'/'+name));
await page.waitForFunction(n=>window.finished===n,index+1);
expected.push(...names.map(name=>({name,hash:crypto.createHash('sha256').update(fs.readFileSync(root+'/'+name)).digest('hex')})));
assert.deepEqual(await page.evaluate(()=>window.uploads),expected);
assert.equal(await page.getByLabel('Compose attachments').inputValue(),'');
assert.equal(await page.getByLabel('Compose attachments').evaluate(el=>el.files.length),0);
}
assert.deepEqual(errors,[]);console.log(JSON.stringify({files:expected.length}));
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});
""".replace("__HANDLER__", function_body(handler)).replace("__HANDLER_NAME__", handler).replace("__UPLOADER__", uploader)
result = subprocess.run(["node", "-e", script, json.dumps(selections), str(tmp_path)],
cwd=Path(__file__).resolve().parents[1], capture_output=True,
text=True, timeout=30)
if result.returncode == 77:
pytest.skip("playwright not installed")
assert result.returncode == 0, result.stdout + result.stderr
assert json.loads(result.stdout)["files"] == sum(map(len, selections))
-39
View File
@@ -1,8 +1,3 @@
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
import routes.font_routes as font_routes
from routes.font_routes import _derive_family from routes.font_routes import _derive_family
@@ -14,37 +9,3 @@ def test_derive_family_splits_common_family_suffixes():
assert _derive_family("FiraCode-SemiBold.ttf") == "Fira Code" assert _derive_family("FiraCode-SemiBold.ttf") == "Fira Code"
assert _derive_family("NotoSans-Bold.otf") == "Noto Sans" assert _derive_family("NotoSans-Bold.otf") == "Noto Sans"
assert _derive_family("RobotoSlab-Bold.woff2") == "Roboto Slab" assert _derive_family("RobotoSlab-Bold.woff2") == "Roboto Slab"
@pytest.mark.parametrize("filename,expected", [
("JetBrainsMono-SemiBoldItalic.woff2", "JetBrains Mono"),
("FiraCode-BoldItalic.ttf", "Fira Code"),
("NotoSans-ExtraLightItalic.otf", "Noto Sans"),
("RobotoSlab-UltraBoldOblique.WOFF2", "Roboto Slab"),
("SourceSansPro-Bold-Italic.woff", "Source Sans Pro"),
("FiraCode-SemiBold_Italic.ttf", "Fira Code"),
("FiraCode-Italic.woff2", "Fira Code"),
("FiraCode-VF.woff2", "Fira Code"),
("MyFont-Book.woff2", "My Font Book"),
])
def test_compound_weight_and_style_are_not_part_of_the_family(filename, expected):
assert _derive_family(filename) == expected
def test_custom_font_endpoint_groups_all_styles_under_one_family(tmp_path, monkeypatch):
files = ["JetBrainsMono-Regular.woff2", "JetBrainsMono-SemiBoldItalic.woff2",
"JetBrainsMono-Bold-Oblique.ttf"]
for name in [*files, "OtherFont-Regular.otf", "README.md"]:
(tmp_path / name).write_bytes(b"filename-only discovery fixture")
monkeypatch.setattr(font_routes, "CUSTOM_FONTS_DIR", str(tmp_path))
app = FastAPI()
app.include_router(font_routes.setup_font_routes())
with TestClient(app) as client:
response = client.get("/api/fonts/custom")
assert response.status_code == 200
fonts = response.json()["fonts"]
assert set(fonts) == {"JetBrains Mono", "Other Font"}
assert fonts["JetBrains Mono"] == [
{"file": name, "url": f"/static/fonts/custom/{name}", "format": name.rsplit(".", 1)[1]}
for name in sorted(files)
]
-37
View File
@@ -8,8 +8,6 @@ label with `[^*]+`, so they could not match a label like "Email *" / "State *"
stayed empty and the exported PDF (and signed-reply attachment) came out blank stayed empty and the exported PDF (and signed-reply attachment) came out blank
for that field, with no error. for that field, with no error.
""" """
import pytest
from src.pdf_form_doc import render_form_as_markdown, parse_markdown_to_values from src.pdf_form_doc import render_form_as_markdown, parse_markdown_to_values
@@ -40,38 +38,3 @@ def test_plain_labels_and_colon_values_unaffected():
vals = parse_markdown_to_values(md) vals = parse_markdown_to_values(md)
assert vals["name"] == "Alice" assert vals["name"] == "Alice"
assert vals["time"] == "9:00 sharp" assert vals["time"] == "9:00 sharp"
@pytest.mark.parametrize("name", ["Prénom", "Año", "İsim", "氏名", "اسم", "n²", "Cafe\u0301"])
@pytest.mark.parametrize("field_type, value", [
("text", "Alice"), ("checkbox", True), ("choice", "Yes"),
])
def test_unicode_field_names_survive_export_roundtrip(name, field_type, value):
fields = [{"name": name, "label": "Field *", "type": field_type,
"value": value, "options": ["Yes", "No"], "page": 1}]
markdown = render_form_as_markdown(fields, "upload", "Form")
marker_name = markdown.split("<!-- field=", 1)[1].split(" type=", 1)[0]
assert marker_name.isascii()
assert parse_markdown_to_values(markdown) == {name: value}
def test_previously_saved_unicode_markers_remain_exportable():
# Older imports emitted Unicode letters literally. Their persisted
# markdown must also work after upgrading, without re-importing the PDF.
markdown = (
"- **First name:** Alice <!-- field=Prénom type=text -->\n"
"- [x] **Consent** <!-- field=同意 type=checkbox -->\n"
"- **Year** [2025 / 2026]: 2026 <!-- field=Año type=choice -->\n"
)
assert parse_markdown_to_values(markdown) == {
"Prénom": "Alice", "同意": True, "Año": "2026",
}
def test_unicode_names_do_not_collide_with_literal_percent_or_ascii_names():
names = ["Prénom", "Pr%C3%A9nom", "Prenom", "Section.1_name-2"]
fields = [{"name": name, "type": "text", "value": str(i), "page": 1}
for i, name in enumerate(names)]
markdown = render_form_as_markdown(fields, "upload", "Form")
assert "field=Section.1_name-2 " in markdown
assert parse_markdown_to_values(markdown) == {name: str(i) for i, name in enumerate(names)}
-46
View File
@@ -1,46 +0,0 @@
import asyncio
import base64
import io
import pytest
from fastapi import HTTPException
from PIL import Image
import routes.gallery_routes as gallery
ROUTES = ['sharpen', 'remove-bg', 'denoise', 'upscale-local', 'enhance-face']
def route(name):
return next(r for r in gallery.setup_gallery_routes().routes if r.path == f'/api/image/{name}')
def call(name, monkeypatch, body):
monkeypatch.setattr(gallery, 'require_privilege', lambda *args: 'alice')
monkeypatch.setattr(gallery, '_local_image_models_enabled', lambda: True)
monkeypatch.setattr(gallery, '_remote_image_ops_base', lambda: '')
class Request:
async def json(self):
return body
return asyncio.run(route(name).endpoint(Request()))
@pytest.mark.parametrize('name', ROUTES)
@pytest.mark.parametrize('body', [
{},
{'image': '!!notb64'},
{'image': base64.b64encode(b'hello').decode()},
])
def test_bad_image_is_a_client_error(name, body, monkeypatch):
with pytest.raises(HTTPException) as exc:
call(name, monkeypatch, body)
assert exc.value.status_code == 400
def test_sharpen_still_accepts_a_valid_image(monkeypatch):
output = io.BytesIO()
Image.new('RGB', (4, 4), 'red').save(output, format='PNG')
result = call('sharpen', monkeypatch, {'image': base64.b64encode(output.getvalue()).decode()})
assert Image.open(io.BytesIO(base64.b64decode(result['image']))).size == (4, 4)
@@ -1,59 +0,0 @@
"""Native image calls must match the server schema and provide a prompt.
A missing prompt must be rejected before the fallback line parser mistakes the
whole arguments object for the requested image.
"""
import json
def _schemas():
import src.agent_tools # noqa: F401 (tool_schemas <-> agent_tools import cycle)
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
return {s["function"]["name"]: s["function"] for s in FUNCTION_TOOL_SCHEMAS}
def _convert(arguments):
import src.agent_tools # noqa: F401
from src.tool_schemas import function_call_to_tool_block
return function_call_to_tool_block("generate_image", json.dumps(arguments))
def test_generate_image_is_offered_to_native_function_calling_models():
schemas = _schemas()
assert "generate_image" in schemas, (
"generate_image has no native schema, so it is filtered out of the tools "
"sent to API models even when the selector picks it"
)
assert schemas["generate_image"]["parameters"]["required"] == ["prompt"]
def test_native_call_reaches_the_executor_with_its_arguments_intact():
from src.tool_execution import _build_mcp_args
args = {"prompt": "a cat riding a bicycle", "model": "gpt-image-1",
"size": "1024x1024", "quality": "high"}
block = _convert(args)
assert block is not None and block.tool_type == "generate_image"
assert _build_mcp_args("generate_image", block.content) == args
def test_call_without_a_prompt_is_rejected_instead_of_drawing_its_own_arguments():
"""No prompt key means the line parser takes the whole JSON blob as the prompt,
so the model gets an image of its own arguments."""
assert _convert({"size": "512x512"}) is None
assert _convert({}) is None
assert _convert({"prompt": " "}) is None
def test_advertised_parameters_are_the_ones_the_image_server_accepts():
"""Anything the schema advertises but the executor drops is a silent no-op."""
from mcp_servers.image_gen_server import list_tools
import asyncio
server_schema = asyncio.run(list_tools())[0].inputSchema
assert set(_schemas()["generate_image"]["parameters"]["properties"]) == set(
server_schema["properties"]
)
-67
View File
@@ -1,67 +0,0 @@
"""Peer injections retain their context role but hydrate as the named agent."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.helpers.js_modules import js_function_source
ROOT = Path(__file__).resolve().parents[1]
def _node(script, *args):
result = subprocess.run(['node', '--input-type=module', '-e', script, *map(json.dumps, args)],
cwd=ROOT, capture_output=True, text=True, timeout=15)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
@pytest.mark.skipif(not shutil.which('node'), reason='node not installed')
@pytest.mark.parametrize('mode', ['parallel', 'round-robin'])
def test_peer_sync_persists_explicit_identity(mode):
source = (ROOT / 'static/js/group.js').read_text()
functions = '\n'.join(js_function_source(name, source) for name in ['_syncAllResponses', '_sendRoundRobin'])
data = _node(r'''
const source=JSON.parse(process.argv[1]),mode=JSON.parse(process.argv[2]);
const _models=[{mid:'model-a',display:'Model A',_groupName:'Persona A'}, {mid:'model-b',display:'Model B'}];
const _participantSessions=['a','b'],API_BASE='http://app',sent=[];
let _abortControllers=[];
const uiModule={scrollHistory(){}};
function _createGroupBubble(model){return {dataset:{raw:'Reply from '+model.mid}};}
async function _streamToHolder(){} function _saveState(){}
async function fetch(url,options){sent.push({url,...JSON.parse(options.body)});return {ok:true};}
eval(source+'\nglobalThis.runSync=async()=> mode==="parallel" ? _syncAllResponses(_models.map(_createGroupBubble)) : _sendRoundRobin("Hi",null);');
await globalThis.runSync();console.log(JSON.stringify(sent));
''', functions, mode)
assert len(data) == 2
for item in data:
message = item['messages'][0]
assert message['role'] == 'user' # Model context must retain peer attribution.
peer = 'model-b' if item['url'].endswith('/a/inject_messages') else 'model-a'
name = 'Model B' if peer == 'model-b' else 'Persona A'
assert message['content'] == f'[{name}]: Reply from {peer}'
assert message['metadata'] == {'group_peer': True, 'group_model': name, 'model': peer}
@pytest.mark.skipif(not shutil.which('node'), reason='node not installed')
@pytest.mark.parametrize('role,content,meta,expected_role,expected_content', [
('user', '[Persona A]: Reply', {'group_peer': True, 'group_model': 'Persona A'}, 'assistant', 'Reply'),
('user', '[Persona A]: Human text', {}, 'user', '[Persona A]: Human text'),
('user', 'Human text', {}, 'user', 'Human text'),
('assistant', 'Parent reply', {'group_model': 'Persona A'}, 'assistant', 'Parent reply'),
('user', 'No matching prefix', {'group_peer': True, 'group_model': 'Persona A'}, 'user', 'No matching prefix'),
('user', '[3]: Human text', {'group_peer': True, 'group_model': 3}, 'user', '[3]: Human text'),
('user', '[Persona A]: Human text', {'group_peer': False, 'group_model': 'Persona A'}, 'user', '[Persona A]: Human text'),
])
def test_history_uses_explicit_peer_identity_only(role, content, meta, expected_role, expected_content):
source = js_function_source('_renderHistoryMessage', (ROOT / 'static/js/sessions.js').read_text())
result = _node(r'''
const source=JSON.parse(process.argv[1]),message=JSON.parse(process.argv[2]);
function _displayHistoryContent(text){return text;}function _stripUserVisionBlocks(text){return text;}
function _addHistoryMessageWithFullRenderer(role,content,model,metadata){return {role,content,metadata};}
const run=eval('('+source+')');console.log(JSON.stringify(run(message,'session-model')));
''', source, {'role': role, 'content': content, 'metadata': meta})
assert result['role'] == expected_role
assert result['content'] == expected_content
assert result['metadata'] == {**meta, '_fromHistory': True}
-33
View File
@@ -1,33 +0,0 @@
from services.hwfit.hf_discovery import _entry_from_collection_item
_SOURCE = {"owner": "Qwen", "provider": "Qwen"}
_COLLECTION = {"title": "Qwen3.6"}
def _item(repo_id, num_params):
item = {"id": repo_id, "type": "model", "pipeline_tag": "text-generation"}
if num_params is not None:
item["numParameters"] = num_params
return item
def test_moe_active_size_is_read_from_name_when_hf_reports_total():
# HF collection items carry numParameters for nearly every repo. The MoE
# active size only exists in the name, so it must still be parsed.
entry = _entry_from_collection_item(_COLLECTION, _item("Qwen/Qwen3.6-35B-A3B", 35_951_822_704), _SOURCE)
assert entry["parameters_raw"] == 35_951_822_704
assert entry["is_moe"] is True
assert entry["active_parameters"] == 3_000_000_000
def test_moe_entry_without_hf_count_falls_back_to_name():
entry = _entry_from_collection_item(_COLLECTION, _item("Qwen/Qwen3-Coder-30B-A3B-Instruct", None), _SOURCE)
assert entry["parameters_raw"] == 30_000_000_000
assert entry["is_moe"] is True
assert entry["active_parameters"] == 3_000_000_000
def test_dense_entry_keeps_hf_count_and_is_not_moe():
entry = _entry_from_collection_item(_COLLECTION, _item("Qwen/Qwen3.6-27B", 27_781_427_952), _SOURCE)
assert entry["parameters_raw"] == 27_781_427_952
assert "is_moe" not in entry
@@ -1,31 +0,0 @@
import pytest
from services.hwfit.hf_discovery import _entry_from_collection_item
_SOURCE = {"owner": "google", "provider": "Google"}
_COLLECTION = {"title": "collection"}
@pytest.mark.parametrize("repo_id,pipeline_tag", [
("google/timesfm-3.0-pytorch", "time-series-forecasting"),
("google/siglip2-base-patch16-224", "zero-shot-image-classification"),
("moonshotai/MoonViT-SO-400M", "image-feature-extraction"),
("zai-org/CogView4-6B", "text-to-image"),
("zai-org/CogVideoX-5b-I2V", "image-to-video"),
("zai-org/CogVideoX1.5-5B", "text-to-video"),
])
def test_non_llm_collection_models_are_skipped(repo_id, pipeline_tag):
item = {"id": repo_id, "type": "model", "pipeline_tag": pipeline_tag, "numParameters": 1_000_000_000}
assert _entry_from_collection_item(_COLLECTION, item, _SOURCE) is None
@pytest.mark.parametrize("repo_id,pipeline_tag", [
("google/gemma-4-12B-it", "image-text-to-text"),
("Qwen/Qwen3.6-27B", "text-generation"),
("Qwen/Qwen3-Embedding-4B", "feature-extraction"),
("NousResearch/DeepHermes-Egregore-v2-RLAIF-8b-Atropos", "reinforcement-learning"),
("Qwen/Qwen3-Next-80B-A3B-Instruct", ""),
])
def test_servable_collection_models_are_kept(repo_id, pipeline_tag):
item = {"id": repo_id, "type": "model", "pipeline_tag": pipeline_tag, "numParameters": 4_000_000_000}
assert _entry_from_collection_item(_COLLECTION, item, _SOURCE) is not None
-74
View File
@@ -1,74 +0,0 @@
"""Explicit quality choices must take precedence over the configured default."""
import asyncio
import json
import pytest
from PIL import Image
@pytest.mark.parametrize("caller", ["json", "lines", "mcp", "edit"])
@pytest.mark.parametrize("requested,configured,expected", [
("medium", "high", "medium"),
("medium", "low", "medium"),
("low", "high", "low"),
("high", "low", "high"),
("auto", "high", "auto"),
(None, "high", "high"),
(None, None, "medium"),
])
def test_image_quality_precedence(monkeypatch, tmp_path, caller, requested, configured, expected):
from src import ai_interaction, settings
from mcp_servers.image_gen_server import call_tool
config = {"image_gen_enabled": True, "image_model": "gpt-image-1"}
if configured is not None:
config["image_quality"] = configured
monkeypatch.setattr(settings, "load_settings", lambda: config)
monkeypatch.setattr(ai_interaction, "_resolve_model", lambda *args, **kwargs:
("http://fixture.invalid/v1/chat/completions", "gpt-image-1", {}))
captured = []
class Response:
status_code = 418
text = "Fixture captured request"
def json(self):
return {"error": {"message": self.text}}
class Client:
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
async def post(self, url, **kwargs):
captured.append(kwargs.get("json") or kwargs.get("data"))
return Response()
monkeypatch.setattr("httpx.AsyncClient", Client)
args = {"prompt": "Fixture image", "model": "gpt-image-1"}
if requested is not None:
args["quality"] = requested
if caller == "json":
operation = ai_interaction.do_generate_image(json.dumps(args), incognito=True)
elif caller == "lines":
content = "Fixture image\ngpt-image-1\n1024x1024"
if requested is not None:
content += "\n" + requested
operation = ai_interaction.do_generate_image(content, incognito=True)
elif caller == "mcp":
operation = call_tool("generate_image", args)
else:
source = tmp_path / "fixture.png"
Image.new("RGB", (2, 2), "white").save(source)
kwargs = {} if requested is None else {"quality": requested}
operation = ai_interaction.do_edit_image("Fixture image", str(source), size="1024x1024",
incognito=True, **kwargs)
result = asyncio.run(operation)
assert "418" in str(result), result
assert len(captured) == 1
assert captured[0]["quality"] == expected
-25
View File
@@ -177,28 +177,3 @@ def test_update_integration_rejects_invalid_base_url_without_changing_existing(
assert exc.value.status_code == 400 assert exc.value.status_code == 400
assert exc.value.detail == message assert exc.value.detail == message
assert integrations.load_integrations()[0]["base_url"] == "https://example.test" assert integrations.load_integrations()[0]["base_url"] == "https://example.test"
@pytest.mark.parametrize("body", [[], "x", 5, None])
def test_create_integration_rejects_non_object_body(integrations_routes, body):
endpoint, session_cookie, http_exception = integrations_routes
create_integration = endpoint("/api/auth/integrations", "POST")
with pytest.raises(http_exception) as exc:
asyncio.run(create_integration(_JsonRequest(body, session_cookie)))
assert exc.value.status_code == 400
assert integrations.load_integrations() == []
@pytest.mark.parametrize("body", [[], "x", 5, None])
def test_update_integration_rejects_non_object_body(integrations_routes, body):
endpoint, session_cookie, http_exception = integrations_routes
update_integration = endpoint("/api/auth/integrations/{integration_id}", "PUT")
integrations.save_integrations([{"id": "existing", "name": "Original", "base_url": "https://example.test"}])
with pytest.raises(http_exception) as exc:
asyncio.run(update_integration("existing", _JsonRequest(body, session_cookie)))
assert exc.value.status_code == 400
assert integrations.load_integrations()[0]["name"] == "Original"
-37
View File
@@ -1091,43 +1091,6 @@ def test_degenerate_stream_guard_allows_long_rows_with_changing_values():
assert guard.check(rows) is None assert guard.check(rows) is None
def _latex_matrix(cell):
rows = (" & ".join(cell(i, j) for j in range(8)) for i in range(8))
return "$$\\begin{pmatrix}\n" + " \\\\\n".join(rows) + "\n\\end{pmatrix}$$"
@pytest.mark.parametrize("text", [
_latex_matrix(lambda i, j: "0"),
_latex_matrix(lambda i, j: "1" if i == j else "0"),
"buf = [" + ", ".join("0" for _ in range(64)) + "]",
])
@pytest.mark.parametrize("chunk", [1, 6, 64])
def test_degenerate_stream_guard_allows_numeric_data(text, chunk):
"""#5560: zero/identity matrices and padded buffers repeat numbers by
design; they are not token collapse."""
guard = llm_core._DegenerateStreamGuard("math-model")
assert all(guard.check(text[i:i + chunk]) is None for i in range(0, len(text), chunk))
def test_degenerate_stream_guard_still_stops_an_endless_number_run():
guard = llm_core._DegenerateStreamGuard("looping-model")
chunk = guard.check("0 " * 3000)
assert chunk is not None
assert "numbers in a row" in chunk
def test_degenerate_stream_guard_still_stops_a_mixed_word_number_loop():
guard = llm_core._DegenerateStreamGuard("looping-model")
chunk = guard.check("row 0 0 0 0 " * 300)
assert chunk is not None
assert "repeated" in chunk
def test_terminal_stream_retries_degenerate_generation_before_emitting(monkeypatch): def test_terminal_stream_retries_degenerate_generation_before_emitting(monkeypatch):
calls = 0 calls = 0
requests = [] requests = []
+1 -19
View File
@@ -51,7 +51,7 @@ async def test_cron_create_edit_resume_and_invalid_edit_rollback(storage):
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize('expression', [None, '', 'bad', '99 99 * * *', '* * * * * *', '* * * * * * *']) @pytest.mark.parametrize('expression', [None, '', 'bad', '99 99 * * *'])
async def test_invalid_cron_does_not_create_inert_task(storage, expression): async def test_invalid_cron_does_not_create_inert_task(storage, expression):
result = await call(action='create', name='Invalid', prompt='Test', schedule='cron', result = await call(action='create', name='Invalid', prompt='Test', schedule='cron',
cron_expression=expression) cron_expression=expression)
@@ -171,21 +171,3 @@ async def test_invalid_cron_retime_rolls_back_all_edits(storage):
row = db.get(database.ScheduledTask, result['task_id']) row = db.get(database.ScheduledTask, result['task_id'])
assert row.name == 'Digest' and row.cron_expression == '15 9 * * 1,3,5' assert row.name == 'Digest' and row.cron_expression == '15 9 * * 1,3,5'
assert row.next_run == datetime(2026, 10, 5, 9, 15) assert row.next_run == datetime(2026, 10, 5, 9, 15)
@pytest.mark.parametrize('expression', ['* * * * *', '15 9 * * 1,3,5', '*/5 * * * *'])
def test_is_valid_cron_accepts_five_fields(expression):
from src.task_scheduler import is_valid_cron
assert is_valid_cron(expression)
@pytest.mark.parametrize('expression', [None, '', 'bad', '99 99 * * *', '* * * * * *', '* * * * * * *', '* * * *'])
def test_is_valid_cron_rejects_everything_else(expression):
from src.task_scheduler import is_valid_cron
assert not is_valid_cron(expression)
@pytest.mark.parametrize('expression', ['* * * * * *', '* * * * * * *'])
def test_compute_next_run_refuses_a_seconds_field(expression):
from src.task_scheduler import compute_next_run
assert compute_next_run('cron', '09:00', None, None, cron_expression=expression) is None
-33
View File
@@ -1,33 +0,0 @@
"""A malformed provider row must not hide its valid neighboring models."""
import pytest
from src.model_discovery import ModelDiscovery
@pytest.mark.parametrize('wrapped', [False, True], ids=['bare', 'openai'])
@pytest.mark.parametrize('invalid_id', [None, '', 0, False, [], {}, 42, True, ['bad'], {'bad': 'id'}])
def test_discovery_keeps_valid_models_beside_invalid_ids(monkeypatch, wrapped, invalid_id):
discovery = ModelDiscovery('fixture')
rows = [{'id': '/valid-model'}, {'id': invalid_id}, 'not a row', None, {'id': 'second-model'}]
payload = {'data': rows} if wrapped else rows
class Response:
is_success = True
def json(self):return payload
monkeypatch.setattr('src.model_discovery.httpx.get', lambda *_args, **_kwargs: Response())
monkeypatch.setattr(discovery, '_fingerprint_provider', lambda *_:'fixture')
result = discovery._check_port('fixture', 8080)
assert result is not None
assert result['models'] == ['/valid-model', 'second-model']
assert result['models_display'] == ['valid-model', 'second-model']
assert result['provider'] == 'fixture'
@pytest.mark.parametrize('payload', [{'data': [{'id': 42}]}, [{'id': ['bad']}], {'data': []}])
def test_discovery_with_no_valid_ids_stays_unavailable(monkeypatch, payload):
class Response:
is_success = True
def json(self):return payload
monkeypatch.setattr('src.model_discovery.httpx.get', lambda *_args, **_kwargs:Response())
assert ModelDiscovery('fixture')._check_port('fixture',8080) is None
-82
View File
@@ -1,82 +0,0 @@
"""Explicit null must clear nullable note fields; omission must preserve them."""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from tests.helpers.database import disposable_database
@pytest.fixture
def client(tmp_path, monkeypatch):
import routes.note.note_routes as routes
with disposable_database(tmp_path) as factory:
monkeypatch.setattr(routes, "SessionLocal", factory)
app = FastAPI()
@app.middleware("http")
async def identity(request, call_next):
request.state.current_user = request.headers.get("x-test-user", "alice")
return await call_next(request)
monkeypatch.setattr(routes, "_scheduler_ref", None)
app.include_router(routes.setup_note_routes())
with TestClient(app) as api:
yield api
@pytest.mark.parametrize("field,value", [
("due_date", "2030-10-08T09:00:00"),
("label", "home"),
("image_url", "https://example.com/note.png"),
("content", "Saved body"),
("items", [{"text": "Saved item", "done": False}]),
("color", "blue"),
("gallery_id", "gallery-1"),
("agent_session_id", "chat-1"),
])
def test_explicit_null_clears_saved_field(client, field, value):
created = client.post("/api/notes", json={"title": "Test note"})
assert created.status_code == 200, created.text
path = f"/api/notes/{created.json()['id']}"
populated = client.put(path, json={field: value})
assert populated.status_code == 200, populated.text
assert client.get(path).json()[field] == value
updated = client.put(path, json={field: None})
assert updated.status_code == 200, updated.text
saved = client.get(path)
assert saved.status_code == 200, saved.text
assert saved.json()[field] is None
assert updated.json()[field] is None
def test_omitted_fields_survive_title_edit(client):
fields = {
"due_date": "2030-10-08T09:00:00",
"label": "home",
"image_url": "https://example.com/note.png",
"content": "Saved body",
"items": [{"text": "Saved item", "done": False}],
"color": "blue",
"gallery_id": "gallery-1",
}
created = client.post("/api/notes", json={"title": "Before", **fields})
assert created.status_code == 200, created.text
path = f"/api/notes/{created.json()['id']}"
updated = client.put(path, json={"title": "After"})
assert updated.status_code == 200, updated.text
saved = client.get(path).json()
assert saved["title"] == "After"
for field, value in fields.items():
assert saved[field] == value
def test_null_update_cannot_clear_another_users_note(client):
created = client.post("/api/notes", json={"title": "Private", "label": "home"})
assert created.status_code == 200, created.text
path = f"/api/notes/{created.json()['id']}"
response = client.put(path, json={"label": None}, headers={"x-test-user": "bob"})
assert response.status_code == 404
assert client.get(path).json()["label"] == "home"
-43
View File
@@ -1,43 +0,0 @@
"""A detached Notes descendant must not hold output open or kill a bystander."""
import os
import runpy
import subprocess
import sys
from pathlib import Path
import pytest
from src import process_lifecycle as lifecycle
ROOT = Path(__file__).resolve().parents[1]
@pytest.mark.skipif(os.name == "nt", reason="POSIX detached-session regression; Windows uses taskkill tree")
def test_notes_cleanup_reaps_detached_pipe_holder_and_preserves_bystander():
stop = runpy.run_path(str(ROOT / "scripts/verify_notes_browser_runtime.py"))["_stop_owned_server"]
child = "import time; time.sleep(60)"
program = ("import subprocess,sys,time; "
f"p=subprocess.Popen([sys.executable,'-c',{child!r}],start_new_session=True); "
"print(p.pid,flush=True); time.sleep(60)")
with subprocess.Popen([sys.executable, "-c", child]) as bystander:
server = subprocess.Popen([sys.executable, "-c", program], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, start_new_session=True)
root_identity = lifecycle.ProcessIdentity.capture(server.pid)
detached = None
try:
child_pid = int(server.stdout.readline())
detached = lifecycle.ProcessIdentity.capture(child_pid)
assert os.getpgid(child_pid) == child_pid
stop(server, root_identity)
assert server.poll() is not None
assert detached.exited()
assert bystander.poll() is None, "cleanup touched an unrelated process"
finally:
# Test-owned fallback cleanup also runs when the negative control
# intentionally substitutes the old parent-only teardown.
targets = [identity for identity in (detached, root_identity) if identity]
lifecycle.terminate_identities(targets, steps=lifecycle.term_kill_steps(1))
server.wait(timeout=3)
if server.stdout:
server.stdout.close()
bystander.terminate()
bystander.wait(timeout=3)
+1 -3
View File
@@ -144,12 +144,10 @@ def test_editor_agent_endpoint_reports_scoped_tools():
async def _route_contract(monkeypatch, message, *, attachment_meta=(), form=None, messages_out=None): async def _route_contract(monkeypatch, message, *, attachment_meta=(), form=None, messages_out=None):
from routes import chat_routes from routes import chat_routes
from src import tool_security, tool_utils from src import tool_security
from tests.test_foreground_model_routing import _RouteRequest, _chat_stream_endpoint from tests.test_foreground_model_routing import _RouteRequest, _chat_stream_endpoint
with monkeypatch.context() as route_patch: with monkeypatch.context() as route_patch:
# This built-in catalog fixture has no configured external MCP servers.
route_patch.setattr(tool_utils, "get_mcp_manager", lambda: None)
endpoint = _chat_stream_endpoint( endpoint = _chat_stream_endpoint(
route_patch, "agent", {}, route_patch, "agent", {},
session_model="odysseus-qwen3.5-tools-pre-heretic", session_model="odysseus-qwen3.5-tools-pre-heretic",
@@ -303,52 +303,3 @@ def test_teardown_rejects_unconfined_programmatic_paths():
res = _run_teardown(options={"resultsDir": "/etc"}) res = _run_teardown(options={"resultsDir": "/etc"})
assert res.returncode == 1 assert res.returncode == 1
assert "Security rejection" in res.stderr assert "Security rejection" in res.stderr
def _disposable_ci_fixture(tmp_path):
data = tmp_path / "odysseus-photo-editor-e2e-fixture"
data.mkdir()
database = data / "app.db"
(data / ".disposable-ci-fixture.json").write_text(json.dumps({
"synthetic": True, "authenticated": True, "database": str(database),
}))
return {"CI": "true", "GITHUB_ACTIONS": "true",
"ODYSSEUS_E2E_DISPOSABLE_ARTIFACTS": "1",
"PHOTO_EDITOR_E2E_DATA_DIR": str(data), "PHOTO_EDITOR_E2E_DB_PATH": str(database),
"PHOTO_EDITOR_E2E_AUTH": "1", "ODYSSEUS_TEST_PYTHON": sys.executable}
def test_disposable_ci_retains_only_sanitized_failure_artifacts(tmp_path):
results = tmp_path / "results"
results.mkdir()
trace = results / "trace.zip"
with zipfile.ZipFile(trace, "w") as archive:
archive.writestr("0-trace.trace", json.dumps({"type": "action", "name": "fill", "params": {"value": "synthetic-secret"}}) + "\n")
screenshot = results / "failure.png"
screenshot.write_bytes(b"synthetic screenshot")
response = _run_teardown(_disposable_ci_fixture(tmp_path), {"resultsDir": str(results)})
assert response.returncode == 0, response.stderr
with zipfile.ZipFile(trace) as archive:
assert json.loads(archive.read("0-trace.trace"))["params"]["value"] == "[REDACTED]"
assert screenshot.exists()
@pytest.mark.parametrize("defect", ["missing-marker", "wrong-database", "unauthenticated", "sanitizer-failure"])
def test_disposable_ci_artifact_failures_purge_closed(tmp_path, defect):
results = tmp_path / "results"
results.mkdir()
(results / "trace.zip").write_bytes(b"invalid sensitive archive")
(results / "failure.png").write_bytes(b"sensitive screenshot")
environment = _disposable_ci_fixture(tmp_path)
if defect == "missing-marker":
(Path(environment["PHOTO_EDITOR_E2E_DATA_DIR"]) / ".disposable-ci-fixture.json").unlink()
elif defect == "wrong-database":
environment["PHOTO_EDITOR_E2E_DB_PATH"] = str(tmp_path / "other.db")
elif defect == "unauthenticated":
environment["PHOTO_EDITOR_E2E_AUTH"] = "0"
else:
environment["ODYSSEUS_TEST_PYTHON"] = "/missing-python"
response = _run_teardown(environment, {"resultsDir": str(results)})
assert response.returncode == 0, response.stderr
assert not (results / "trace.zip").exists()
assert not (results / "failure.png").exists()
+34 -155
View File
@@ -12,8 +12,6 @@ exercising every procfs-touching code path on both a Linux and a non-Linux
host. ``test_cookbook_stop_without_procfs.py`` covers the behaviour itself. host. ``test_cookbook_stop_without_procfs.py`` covers the behaviour itself.
""" """
import ast import ast
import pytest
from pathlib import Path from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
@@ -77,112 +75,37 @@ def _scan_sites(tree: ast.AST) -> list[ast.Call]:
return sites return sites
def _condition_requires_procfs(node: ast.AST, truth: bool) -> bool: def _guard_lines(tree: ast.AST) -> list[int]:
"""Whether this Boolean outcome proves a direct procfs check succeeded. """Line numbers of calls that test whether procfs is present."""
lines = []
An ignored call, another function's check, or a check on only one possible for node in ast.walk(tree):
OR branch provides no proof. Keep unknown expressions conservative. if not isinstance(node, ast.Call):
""" continue
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not): func = node.func
return _condition_requires_procfs(node.operand, not truth) name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "")
if isinstance(node, ast.BoolOp): if name not in _GUARD_FUNCS:
facts = [_condition_requires_procfs(value, truth) for value in node.values] continue
all_values_required = isinstance(node.op, ast.And) == truth subject_is_procfs = (
return any(facts) if all_values_required else all(facts) name in {"has_procfs", "is_wsl"}
if not isinstance(node, ast.Call): or (isinstance(func, ast.Attribute) and _is_procfs_root(func.value))
return False or any(_is_procfs_root(arg) for arg in node.args)
func = node.func )
name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "") if subject_is_procfs:
subject_is_procfs = ( lines.append(node.lineno)
name == "has_procfs" return lines
or (isinstance(func, ast.Attribute) and _is_procfs_root(func.value))
or any(_is_procfs_root(arg) for arg in node.args)
)
return truth and name in _GUARD_FUNCS and subject_is_procfs
def _guarded_scan_ids(tree: ast.AST) -> set[int]: def _enclosing_scope(tree: ast.AST, node: ast.AST) -> ast.AST:
"""Follow conditional/early-exit paths instead of comparing line numbers. """Smallest function/module scope containing ``node``."""
best = tree
Each function starts with no procfs assumption. Facts from a loop or try for candidate in ast.walk(tree):
body cannot escape to code which may run without that body completing. if not isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef)):
This intentionally requires direct checks; arbitrary aliases/call graphs continue
are not a proof that procfs exists at the enumeration site. end = getattr(candidate, "end_lineno", None) or candidate.lineno
""" if candidate.lineno <= node.lineno <= end:
scans = {id(site) for site in _scan_sites(tree)} if best is tree or candidate.lineno > best.lineno:
guarded = set() best = candidate
return best
def expression(node, present):
if node is None:
return
if id(node) in scans and present:
guarded.add(id(node))
if isinstance(node, ast.BoolOp):
for value in node.values:
expression(value, present)
# Later AND operands run only after a true prefix; later OR
# operands run only after a false prefix.
present = present or _condition_requires_procfs(value, isinstance(node.op, ast.And))
elif isinstance(node, ast.IfExp):
expression(node.test, present)
expression(node.body, present or _condition_requires_procfs(node.test, True))
expression(node.orelse, present or _condition_requires_procfs(node.test, False))
elif isinstance(node, (ast.ListComp, ast.SetComp, ast.GeneratorExp, ast.DictComp)):
for generator in node.generators:
expression(generator.iter, present)
for condition in generator.ifs:
expression(condition, present)
present = present or _condition_requires_procfs(condition, True)
for attr in ("elt", "key", "value"):
expression(getattr(node, attr, None), present)
elif isinstance(node, ast.Lambda):
expression(node.body, False)
else:
for child in ast.iter_child_nodes(node):
expression(child, present)
def block(statements, present):
for statement in statements:
if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
block(statement.body, False)
for value in statement.decorator_list + statement.args.defaults:
expression(value, present)
elif isinstance(statement, ast.ClassDef):
block(statement.body, present)
elif isinstance(statement, ast.If):
expression(statement.test, present)
yes = block(statement.body, present or _condition_requires_procfs(statement.test, True))
no = block(statement.orelse, present or _condition_requires_procfs(statement.test, False))
continuing = [fact for fact in (yes, no) if fact is not None]
if not continuing:
return None
present = all(continuing)
elif isinstance(statement, (ast.Return, ast.Raise, ast.Break, ast.Continue)):
for child in ast.iter_child_nodes(statement):
expression(child, present)
return None
elif isinstance(statement, (ast.For, ast.AsyncFor, ast.While)):
condition = statement.test if isinstance(statement, ast.While) else statement.iter
expression(condition, present)
body_present = present or (isinstance(statement, ast.While) and _condition_requires_procfs(condition, True))
block(statement.body, body_present)
block(statement.orelse, present)
elif isinstance(statement, (ast.Try, getattr(ast, "TryStar", ast.Try))):
block(statement.body, present)
block(statement.orelse, present)
block(statement.finalbody, present)
for handler in statement.handlers:
block(handler.body, present)
elif isinstance(statement, (ast.With, ast.AsyncWith)):
for item in statement.items:
expression(item.context_expr, present)
block(statement.body, present)
else:
expression(statement, present)
return present
block(tree.body, False)
return guarded
def _app_python_files() -> list[Path]: def _app_python_files() -> list[Path]:
@@ -216,10 +139,12 @@ def _collect_sites() -> tuple[list[str], list[str]]:
sites = _scan_sites(tree) sites = _scan_sites(tree)
if not sites: if not sites:
continue continue
guards = _guarded_scan_ids(tree) guards = _guard_lines(tree)
for site in sites: for site in sites:
scope = _enclosing_scope(tree, site)
start = getattr(scope, "lineno", 0)
label = f"{path.relative_to(REPO_ROOT)}:{site.lineno}" label = f"{path.relative_to(REPO_ROOT)}:{site.lineno}"
if id(site) in guards: if any(start <= g < site.lineno for g in guards):
guarded.append(label) guarded.append(label)
else: else:
unguarded.append(label) unguarded.append(label)
@@ -229,7 +154,7 @@ def _collect_sites() -> tuple[list[str], list[str]]:
def test_every_procfs_scan_is_guarded_by_an_existence_check(): def test_every_procfs_scan_is_guarded_by_an_existence_check():
_guarded, unguarded = _collect_sites() _guarded, unguarded = _collect_sites()
assert not unguarded, ( assert not unguarded, (
"procfs pid scans without an effective conditional existence guard — " "procfs pid scans with no existence check in the enclosing function — "
"these raise FileNotFoundError on macOS and Windows: " "these raise FileNotFoundError on macOS and Windows: "
+ ", ".join(unguarded) + ", ".join(unguarded)
) )
@@ -251,49 +176,3 @@ def test_the_guard_detector_still_sees_the_known_scans():
f"root was renamed. Found: {sorted(found)}" f"root was renamed. Found: {sorted(found)}"
) )
assert len(found) >= 3, f"expected at least 3 procfs scans, found {sorted(found)}" assert len(found) >= 3, f"expected at least 3 procfs scans, found {sorted(found)}"
@pytest.mark.parametrize("body", [
'Path("/proc").exists(); os.listdir("/proc")',
'if Path("/proc").exists():\n pass\nos.listdir("/proc")',
'if not Path("/proc").exists():\n os.listdir("/proc")',
'if ready or Path("/proc").exists():\n os.listdir("/proc")',
'if ready:\n Path("/proc").exists()\nos.listdir("/proc")',
'os.listdir("/proc"); Path("/proc").exists()',
'if Path("/tmp").exists():\n os.listdir("/proc")',
'def other():\n Path("/proc").exists()\nos.listdir("/proc")',
'if not has_procfs():\n pass\nos.listdir("/proc")',
'for x in []:\n if not has_procfs():\n return\nos.listdir("/proc")',
'if has_procfs():\n return\nelse:\n os.listdir("/proc")',
'[pid for pid in os.listdir("/proc") if has_procfs()]',
], ids=["ignored", "expired-branch", "inverted", "unsafe-or", "sibling-branch",
"too-late", "wrong-path", "other-function", "no-early-exit", "loop-may-not-run",
"wrong-else", "late-comprehension-filter"])
def test_guard_detector_rejects_ineffective_checks(body):
tree = ast.parse("def scan():\n" + "\n".join(" " + line for line in body.splitlines()))
sites = _scan_sites(tree)
assert sites, "negative control must contain a recognized enumeration"
assert any(id(site) not in _guarded_scan_ids(tree) for site in sites)
@pytest.mark.parametrize("body", [
'if Path("/proc").exists():\n os.listdir("/proc")',
'if not Path("/proc").exists():\n return\nos.listdir("/proc")',
'if not has_procfs():\n raise RuntimeError()\nos.listdir(PROC_ROOT)',
'if has_procfs():\n pass\nelse:\n return\nPROC_ROOT.iterdir()',
'if ready and has_procfs():\n os.scandir("/proc")',
'if not has_procfs() or disabled:\n return\nPROC_ROOT.glob("*")',
'if sys.platform == "linux" and has_procfs():\n PROC_ROOT.iterdir()',
'for item in items:\n if not has_procfs():\n continue\n os.listdir("/proc")',
'if os.path.isdir("/proc"):\n os.listdir("/proc")',
'if has_procfs() and list(PROC_ROOT.iterdir()):\n pass',
'if not has_procfs() or list(PROC_ROOT.iterdir()):\n pass',
'[pid for item in items if has_procfs() for pid in os.listdir("/proc")]',
], ids=["positive-branch", "early-return", "early-raise", "else-exits", "and",
"negative-or", "platform-and-procfs", "loop-continue", "isdir",
"short-circuit-and", "short-circuit-or", "guarded-comprehension"])
def test_guard_detector_preserves_effective_checks(body):
tree = ast.parse("def scan():\n" + "\n".join(" " + line for line in body.splitlines()))
sites = _scan_sites(tree)
assert sites, "positive control must contain a recognized enumeration"
assert all(id(site) in _guarded_scan_ids(tree) for site in sites)
+3 -4
View File
@@ -128,7 +128,7 @@ def test_auto_sort_keeps_fresh_session_while_first_response_is_pending(monkeypat
db.close() db.close()
def test_auto_sort_keeps_old_nonempty_sessions_even_with_throwaway_title(monkeypatch): def test_auto_sort_still_deletes_old_throwaway_sessions(monkeypatch):
session_factory = _make_session_factory() session_factory = _make_session_factory()
_install_session_factory(monkeypatch, session_factory) _install_session_factory(monkeypatch, session_factory)
@@ -160,8 +160,7 @@ def test_auto_sort_keeps_old_nonempty_sessions_even_with_throwaway_title(monkeyp
db = session_factory() db = session_factory()
try: try:
assert db.query(DbSession).filter(DbSession.id == sid).first() is not None assert db.query(DbSession).filter(DbSession.id == sid).first() is None
assert db.query(DbMessage).filter(DbMessage.session_id == sid).count() == 1 assert "Cleaned 1 sessions" in result
assert "Cleaned 0 sessions" in result
finally: finally:
db.close() db.close()
+9 -13
View File
@@ -19,7 +19,6 @@ from core.database import ChatMessage as DbMessage
from core.database import Session as DbSession from core.database import Session as DbSession
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False) _TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_TMPDB.close() # SQLite owns its connections; release the Windows file handle.
_ENGINE = create_engine( _ENGINE = create_engine(
f"sqlite:///{_TMPDB.name}", f"sqlite:///{_TMPDB.name}",
connect_args={"check_same_thread": False}, connect_args={"check_same_thread": False},
@@ -231,14 +230,12 @@ def test_patch_session_updates_persisted_cwd(monkeypatch):
def test_auto_sort_skip_llm_cleans_owner_stamped_sessions_when_auth_disabled(monkeypatch): def test_auto_sort_skip_llm_cleans_owner_stamped_sessions_when_auth_disabled(monkeypatch):
import routes.session_routes as sr import routes.session_routes as sr
from fastapi import APIRouter
from unittest.mock import MagicMock from unittest.mock import MagicMock
_stub_multipart_if_missing(monkeypatch) _stub_multipart_if_missing(monkeypatch)
monkeypatch.setenv("AUTH_ENABLED", "false") monkeypatch.setenv("AUTH_ENABLED", "false")
monkeypatch.setattr(sr, "SessionLocal", _TS) monkeypatch.setattr(sr, "SessionLocal", _TS)
monkeypatch.setattr(sr, "effective_user", lambda request: None) monkeypatch.setattr(sr, "effective_user", lambda request: None)
monkeypatch.setattr(sr, "router", APIRouter(prefix="/api"))
sid = str(uuid.uuid4()) sid = str(uuid.uuid4())
old_time = cdb.utcnow_naive() - timedelta(hours=2) old_time = cdb.utcnow_naive() - timedelta(hours=2)
@@ -253,12 +250,19 @@ def test_auto_sort_skip_llm_cleans_owner_stamped_sessions_when_auth_disabled(mon
endpoint_url="http://localhost", endpoint_url="http://localhost",
model="gpt-4", model="gpt-4",
archived=False, archived=False,
message_count=0, message_count=1,
created_at=old_time, created_at=old_time,
updated_at=old_time, updated_at=old_time,
last_message_at=old_time, last_message_at=old_time,
last_accessed=old_time, last_accessed=old_time,
)) ))
db.add(DbMessage(
id="m-" + uuid.uuid4().hex,
session_id=sid,
role="user",
content="hi",
timestamp=old_time,
))
db.commit() db.commit()
finally: finally:
db.close() db.close()
@@ -266,13 +270,6 @@ def test_auto_sort_skip_llm_cleans_owner_stamped_sessions_when_auth_disabled(mon
session = MagicMock(id=sid, name="New chat", model="gpt-4", endpoint_url="http://localhost", rag=False, archived=False) session = MagicMock(id=sid, name="New chat", model="gpt-4", endpoint_url="http://localhost", rag=False, archived=False)
sm = MagicMock() sm = MagicMock()
sm.get_sessions_for_user.return_value = {sid: session} sm.get_sessions_for_user.return_value = {sid: session}
def delete_session(session_id):
with _TS() as db:
db.query(DbSession).filter(DbSession.id == session_id).delete()
db.commit()
sm.delete_session.side_effect = delete_session
router = sr.setup_session_routes(sm, {}) router = sr.setup_session_routes(sm, {})
endpoint = next(r.endpoint for r in router.routes endpoint = next(r.endpoint for r in router.routes
if getattr(r, "path", "") == "/api/sessions/auto-sort" if getattr(r, "path", "") == "/api/sessions/auto-sort"
@@ -280,8 +277,7 @@ def test_auto_sort_skip_llm_cleans_owner_stamped_sessions_when_auth_disabled(mon
result = endpoint(request=MagicMock(), skip_llm=True) result = endpoint(request=MagicMock(), skip_llm=True)
assert result["deleted_empty"] == 1 assert result["deleted_throwaway"] == 1
assert result["deleted_throwaway"] == 0
db = _TS() db = _TS()
try: try:
assert db.query(DbSession).filter(DbSession.id == sid).first() is None assert db.query(DbSession).filter(DbSession.id == sid).first() is None
-76
View File
@@ -1,76 +0,0 @@
"""Raw skill source needs a usable editor height without hiding its footer."""
import json
import os
from pathlib import Path
import shutil
import subprocess
from bs4 import BeautifulSoup
import pytest
@pytest.mark.parametrize("width", [1440, 390])
@pytest.mark.parametrize("lines", [8, 40, 400])
def test_skill_editor_source_height(tmp_path, width, lines):
if not shutil.which("node"):
pytest.skip("node not installed")
repo = Path(__file__).resolve().parents[1]
index = BeautifulSoup((repo / "static/index.html").read_text(), "html.parser")
modal = index.select_one("#memory-modal")
modal["class"] = [value for value in modal.get("class", []) if value != "hidden"]
for panel in modal.select("[data-memory-panel]"):
classes = set(panel.get("class", []))
if panel["data-memory-panel"] == "skills":
classes.discard("hidden")
else:
classes.add("hidden")
panel["class"] = sorted(classes)
styles = "".join(str(node) for node in index.select('link[rel="stylesheet"]'))
document = tmp_path / "skills.html"
document.write_text(styles + str(modal) + str(index.select_one("#toast")), encoding="utf-8")
script = r"""
const fs=require('node:fs'),assert=require('node:assert/strict');
let chromium;
try{({chromium}=require('playwright'));}catch(e){if(e.code==='MODULE_NOT_FOUND')process.exit(77);throw e;}
(async()=>{
const browser=await chromium.launch({headless:true});
try{
const origin=process.env.ODYSSEUS_TEST_STATIC_ORIGIN,lines=Number(process.argv[3]);
const page=await browser.newPage({viewport:{width:Number(process.argv[2]),height:900}});page.setDefaultTimeout(5000);
let source=Array.from({length:lines},(_,i)=>'Line '+(i+1)+' of synthetic skill source').join('\n'),posts=0;
const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.route(origin+'/api/**',async route=>{
const request=route.request(),path=new URL(request.url()).pathname;let data={};
if(path==='/api/skills')data={skills:[{name:'fixture-height',description:'Source height fixture',status:'draft',source:'user',confidence:0.9}]};
else if(path.endsWith('/markdown')){
if(request.method()==='POST'){posts++;source=request.postDataJSON().markdown;data={ok:true};}else data={markdown:source};
}else if(path.endsWith('/audit-all/status'))data={status:'none'};
await route.fulfill({contentType:'application/json',body:JSON.stringify(data)});
});
await page.goto(origin+'/static/js/storage.js');await page.setContent(fs.readFileSync(process.argv[1],'utf8'));
await page.evaluate(async()=>{const skills=await import('/static/js/skills.js');await skills.loadSkills();});
const card=page.locator('.skill-card[data-skill-name="fixture-height"]');await card.locator('.skill-card-name').click();
await card.getByRole('button',{name:'Edit',exact:true}).click();const editor=card.locator('.skill-md-editor');await editor.waitFor({state:'visible'});
const geometry=await editor.evaluate(el=>{
const box=el.getBoundingClientRect(),lineHeight=parseFloat(getComputedStyle(el).lineHeight);
const modal=el.closest('.modal-content').getBoundingClientRect();
return {height:box.height,visibleLines:el.clientHeight/lineHeight,modalBottom:modal.bottom,viewportHeight:innerHeight};
});
assert(geometry.visibleLines>=Math.min(lines,8),JSON.stringify(geometry));
const save=card.getByRole('button',{name:'Save',exact:true}),bounds=await save.boundingBox();
assert(bounds.y>=0&&bounds.y+bounds.height<=geometry.viewportHeight,JSON.stringify({geometry,bounds}));
assert(geometry.modalBottom<=geometry.viewportHeight+1,JSON.stringify(geometry));
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);
await editor.fill(source+'\nSaved final line');await save.click();await page.waitForFunction(()=>!document.querySelector('.skill-md-editor'));
assert.equal(posts,1);assert(source.endsWith('Saved final line'));assert.deepEqual(errors,[]);
console.log(JSON.stringify({width:process.argv[2],lines,geometry,posts}));
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});
"""
result = subprocess.run(["node", "-e", script, str(document), str(width), str(lines)],
cwd=repo, env=os.environ.copy(), capture_output=True,
text=True, timeout=45)
if result.returncode == 77:
pytest.skip("playwright not installed")
assert result.returncode == 0, result.stdout + result.stderr
assert json.loads(result.stdout)["posts"] == 1
@@ -1,99 +0,0 @@
"""An unloaded or failed SKILL.md must never become editable placeholder text."""
import json
import os
from pathlib import Path
import shutil
import subprocess
from bs4 import BeautifulSoup
import pytest
@pytest.mark.parametrize("width", [1440, 390])
@pytest.mark.parametrize("mode", ["delayed", "error", "menu-error", "empty", "collapse"])
def test_skill_editor_waits_for_source(tmp_path, width, mode):
if not shutil.which("node"):
pytest.skip("node not installed")
repo = Path(__file__).resolve().parents[1]
index = BeautifulSoup((repo / "static/index.html").read_text(), "html.parser")
modal = index.select_one("#memory-modal")
modal["class"] = [value for value in modal.get("class", []) if value != "hidden"]
for panel in modal.select("[data-memory-panel]"):
classes = set(panel.get("class", []))
if panel["data-memory-panel"] == "skills":
classes.discard("hidden")
else:
classes.add("hidden")
panel["class"] = sorted(classes)
styles = "".join(str(node) for node in index.select('link[rel="stylesheet"]'))
document = tmp_path / "skills.html"
document.write_text(styles + str(modal) + str(index.select_one("#toast")), encoding="utf-8")
script = r"""
const fs=require('node:fs'),assert=require('node:assert/strict');
let chromium;try{({chromium}=require('playwright'));}catch(e){if(e.code==='MODULE_NOT_FOUND')process.exit(77);throw e;}
(async()=>{
const browser=await chromium.launch({headless:true});
try{
const origin=process.env.ODYSSEUS_TEST_STATIC_ORIGIN,mode=process.argv[3];
const page=await browser.newPage({viewport:{width:Number(process.argv[2]),height:900}});page.setDefaultTimeout(5000);
const original=mode==='empty'?'':'---\nname: fixture-loading\n---\n\n## Procedure\nOriginal source';
let source=original,posts=0,gets=0,release;const gate=new Promise(r=>release=r),errors=[];
page.on('pageerror',e=>errors.push(e.message));
await page.route(origin+'/api/**',async route=>{
const request=route.request(),path=new URL(request.url()).pathname;let data={};
if(path==='/api/skills')data={skills:[{name:'fixture-loading',description:'Lazy loading fixture',status:'draft',source:'user',confidence:0.9}]};
else if(path.endsWith('/markdown')){
if(request.method()==='POST'){posts++;source=request.postDataJSON().markdown;data={ok:true};}
else{
gets++;if(gets===1)await gate;
if((mode==='error'&&gets===1)||(mode==='menu-error'&&gets<=2)){
await route.fulfill({status:500,contentType:'application/json',body:JSON.stringify({detail:'Fixture read failed'})});return;
}
data={markdown:source};
}
}else if(path.endsWith('/audit-all/status'))data={status:'none'};
await route.fulfill({contentType:'application/json',body:JSON.stringify(data)});
});
await page.goto(origin+'/static/js/storage.js');await page.setContent(fs.readFileSync(process.argv[1],'utf8'));
await page.evaluate(async()=>{const skills=await import('/static/js/skills.js');await skills.loadSkills();});
const card=page.locator('.skill-card[data-skill-name="fixture-loading"]');await card.locator('.skill-card-name').click();
await card.getByText('Loading…',{exact:true}).waitFor();
const edit=card.getByRole('button',{name:'Edit',exact:true});assert(await edit.isDisabled(),'Edit must stay disabled while source is pending');
assert.equal(await card.locator('.skill-md-editor').count(),0);assert.equal(posts,0);
if(mode==='collapse')await card.locator('.skill-chevron-up').click();
release();
if(mode==='error'||mode==='menu-error'){
await card.getByText('Failed to load SKILL.md: Fixture read failed',{exact:true}).waitFor();
assert(await edit.isDisabled(),'Failed source must not be editable');
assert.equal(await card.locator('.skill-md-editor').count(),0);assert.equal(posts,0);
await card.locator('.skill-chevron-up').click();
if(mode==='menu-error'){
await card.locator('.skill-kebab-btn').click();
await page.locator('.skill-kebab-menu:visible').getByRole('button',{name:'Edit',exact:true}).click();
await card.getByText('Failed to load SKILL.md: Fixture read failed',{exact:true}).waitFor();
assert.equal(await card.locator('.skill-md-editor').count(),0);
assert(await edit.isDisabled());assert.equal(posts,0);
await card.locator('.skill-chevron-up').click();
}
await card.locator('.skill-card-name').click();
}else if(mode==='collapse'){
await page.waitForFunction(()=>document.querySelector('.skill-card')?._mdLoaded);
assert.equal(await card.locator('.skill-md-editor').count(),0);
assert(!(await card.getAttribute('class')).includes('doclib-card-expanded'));
await card.locator('.skill-card-name').click();
}
await page.waitForFunction(()=>document.querySelector('.skill-card')?._mdLoaded);
assert(await edit.isEnabled());await edit.click();const editor=card.locator('.skill-md-editor');await editor.waitFor({state:'visible'});assert.equal(await editor.inputValue(),original);
await editor.fill(original+'\nSaved real source');await card.getByRole('button',{name:'Save',exact:true}).click();await page.waitForFunction(()=>!document.querySelector('.skill-md-editor'));
assert.equal(posts,1);assert.equal(source,original+'\nSaved real source');assert.deepEqual(errors,[]);
console.log(JSON.stringify({mode,width:process.argv[2],gets,posts}));
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1;});
"""
result = subprocess.run(["node", "-e", script, str(document), str(width), mode],
cwd=repo, env=os.environ.copy(), capture_output=True,
text=True, timeout=45)
if result.returncode == 77:
pytest.skip("playwright not installed")
assert result.returncode == 0, result.stdout + result.stderr
assert json.loads(result.stdout)["posts"] == 1
-59
View File
@@ -1,59 +0,0 @@
"""Issue #2131: editing a skill must not reinterpret its version as a number."""
import pytest
from services.memory.skill_format import Skill, parse_frontmatter
from services.memory.skills import SkillsManager
@pytest.mark.parametrize(
"raw, expected",
[
("1.10", "1.10"),
("2.20", "2.20"),
("01", "01"),
("0", "0"),
("1.0.0", "1.0.0"),
("true", "true"),
("null", "null"),
('"1.10"', "1.10"),
("'2.20'", "2.20"),
('"release\\u002d01"', "release-01"),
],
)
def test_version_survives_repeated_markdown_saves(raw, expected):
markdown = f"---\nname: demo\nversion: {raw}\nconfidence: 0.80\n---\n"
for _ in range(3):
skill = Skill.from_markdown(markdown)
assert skill.version == expected
assert skill.confidence == 0.8
markdown = skill.to_markdown()
def test_version_preservation_keeps_other_frontmatter_types():
fm, _ = parse_frontmatter(
"---\nversion: 1.10\nconfidence: 0.80\nuses: 3\n"
"enabled: true\ntags: [one, two]\n---\n"
)
assert fm == {
"version": "1.10", "confidence": 0.8, "uses": 3,
"enabled": True, "tags": ["one", "two"],
}
@pytest.mark.parametrize("version_line", ["", "version:\n", 'version: ""\n'])
def test_missing_or_empty_version_retains_default(version_line):
skill = Skill.from_markdown(f"---\nname: demo\n{version_line}---\n")
assert skill.version == "1.0.0"
def test_version_survives_unrelated_edits_on_disk(tmp_path):
manager = SkillsManager(str(tmp_path))
manager.add_skill(name="demo", version="1.10", owner="alice")
for i in range(3):
assert manager.update_skill("demo", {"description": f"Edit {i}"}, owner="alice")
stored = manager.load(owner="alice")
assert stored[0]["version"] == "1.10"
assert stored[0]["description"] == f"Edit {i}"
markdown = next((tmp_path / "skills").rglob("SKILL.md")).read_text(encoding="utf-8")
assert "version: 1.10\n" in markdown
-69
View File
@@ -242,72 +242,3 @@ def test_multiple_trace_archives_in_directory(tmp_path):
with zipfile.ZipFile(z2, "r") as z: with zipfile.ZipFile(z2, "r") as z:
content = json.loads(z.read("0.trace").decode().strip()) content = json.loads(z.read("0.trace").decode().strip())
assert content["params"]["value"] == "[REDACTED]" assert content["params"]["value"] == "[REDACTED]"
def test_fail_closed_malformed_jsonl_record(tmp_path):
"""Malformed JSONL lines in .trace archives fail closed and delete the archive."""
zp = str(tmp_path / "malformed_jsonl.zip")
with zipfile.ZipFile(zp, "w") as z:
z.writestr(
"0-trace.trace",
b'{"type": "action", "params": {"value": "secret123"}}\n'
b'this is invalid json {"password": "leak_raw_pass"}\n'
)
ok = sanitize_trace_archive(zp)
assert ok is False
assert not os.path.exists(zp)
assert not os.path.exists(zp + ".tmp")
def test_fail_closed_malformed_jsonl_truncated(tmp_path):
"""Truncated JSONL records in .network archives fail closed and delete the archive."""
zp = str(tmp_path / "truncated_jsonl.zip")
with zipfile.ZipFile(zp, "w") as z:
z.writestr(
"0-trace.network",
b'{"type": "request", "request": {"url": "https://example.com/api/auth", "headers": [{"name": "Authorization", "value": "Bearer secret_tok"'
)
ok = sanitize_trace_archive(zp)
assert ok is False
assert not os.path.exists(zp)
assert not os.path.exists(zp + ".tmp")
def test_fail_closed_malformed_sensitive_body(tmp_path):
"""Malformed sensitive request body in trace archives fails closed and deletes archive."""
zp = str(tmp_path / "malformed_sensitive_body.zip")
with zipfile.ZipFile(zp, "w") as z:
record = {
"type": "request",
"request": {
"url": "https://example.com/api/login",
"postData": '{"password": "super_secret_pw", "corrupt_trailing": ',
},
}
z.writestr("0-trace.network", json.dumps(record).encode() + b"\n")
ok = sanitize_trace_archive(zp)
assert ok is False
assert not os.path.exists(zp)
assert not os.path.exists(zp + ".tmp")
def test_fail_closed_malformed_har_member(tmp_path):
"""Malformed JSON in .har archives fails closed and deletes the archive."""
zp = str(tmp_path / "malformed_har.zip")
with zipfile.ZipFile(zp, "w") as z:
z.writestr("network.har", b'{"log": {"entries": [ malformed JSON content')
ok = sanitize_trace_archive(zp)
assert ok is False
assert not os.path.exists(zp)
assert not os.path.exists(zp + ".tmp")
def test_fail_closed_invalid_utf8_jsonl_record(tmp_path):
"""Non-UTF8 byte sequences in JSONL trace records fail closed and delete the archive."""
zp = str(tmp_path / "invalid_utf8.zip")
with zipfile.ZipFile(zp, "w") as z:
z.writestr("0-trace.trace", b"\xff\xfe\x00\x00 invalid utf8 raw bytes\n")
ok = sanitize_trace_archive(zp)
assert ok is False
assert not os.path.exists(zp)
assert not os.path.exists(zp + ".tmp")
-591
View File
@@ -1,591 +0,0 @@
"""Tests for the fail-closed artifact verification step (.github/scripts/verify_sanitized_artifacts.py).
Validates that:
1. Safe JUnit XML reporting is preserved across all scenarios.
2. Abnormal runner termination without verified disposable CI fixture purges all diagnostics.
3. Valid disposable CI environments sanitize trace archives fail-closed.
4. Malformed trace archives are deleted fail-closed without corrupting or deleting JUnit XML reports.
5. In-progress temporary files, HAR files, and database dumps are purged unconditionally.
6. Top-level exceptions fail closed to purge all non-XML diagnostics.
7. Negative controls:
- Verifier failure exits nonzero.
- Verifier failure prevents upload in workflow contract.
- Missing verifier execution prevents upload.
- Valid XML containing a synthetic secret is not published unchanged.
- Malformed traces are removed.
- Failed artifact deletion prevents authorization.
- A normal successful run retains safe test results.
"""
from __future__ import annotations
import importlib.util
import json
import os
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
import zipfile
from pathlib import Path
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parents[1]
VERIFY_SCRIPT = REPO_ROOT / ".github" / "scripts" / "verify_sanitized_artifacts.py"
WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "native-test-suites.yml"
spec = importlib.util.spec_from_file_location("verify_sanitized_artifacts", str(VERIFY_SCRIPT))
verify_module = importlib.util.module_from_spec(spec)
sys.modules["verify_sanitized_artifacts"] = verify_module
spec.loader.exec_module(verify_module)
is_valid_disposable_ci_fixture = verify_module.is_valid_disposable_ci_fixture
verify_and_sanitize_artifacts = verify_module.verify_and_sanitize_artifacts
sanitize_and_verify_junit_xml = verify_module.sanitize_and_verify_junit_xml
safe_remove = verify_module.safe_remove
def _setup_disposable_ci(tmp_path: Path) -> dict[str, str]:
data_dir = tmp_path / "odysseus-photo-editor-e2e-fixture"
data_dir.mkdir(parents=True, exist_ok=True)
db_path = data_dir / "app.db"
db_path.write_bytes(b"SQLite format 3\x00")
marker_file = data_dir / ".disposable-ci-fixture.json"
marker_file.write_text(
json.dumps({
"synthetic": True,
"authenticated": True,
"database": str(db_path),
})
)
return {
"CI": "true",
"GITHUB_ACTIONS": "true",
"ODYSSEUS_E2E_DISPOSABLE_ARTIFACTS": "1",
"PHOTO_EDITOR_E2E_DATA_DIR": str(data_dir),
"PHOTO_EDITOR_E2E_DB_PATH": str(db_path),
"PHOTO_EDITOR_E2E_AUTH": "1",
"ODYSSEUS_TEST_PYTHON": sys.executable,
}
def test_preserves_valid_junit_xml_cleanly(tmp_path):
"""Valid JUnit XML files are preserved intact."""
results_dir = tmp_path / "test-results"
results_dir.mkdir()
junit = results_dir / "browser-junit.xml"
junit.write_text('<testsuites><testsuite name="photo-editor" tests="137" failures="0"/></testsuites>')
ok = verify_and_sanitize_artifacts(str(results_dir))
assert ok is True
assert junit.exists()
assert 'tests="137"' in junit.read_text()
def test_normal_successful_run_retains_safe_test_results(tmp_path):
"""A normal successful run retains safe test results without modification."""
results_dir = tmp_path / "test-results"
results_dir.mkdir()
junit = results_dir / "junit.xml"
original_xml = '<testsuites><testsuite name="native-node" tests="43" failures="0"><testcase name="test-1"/></testsuite></testsuites>'
junit.write_text(original_xml)
res = subprocess.run(
[sys.executable, str(VERIFY_SCRIPT), str(results_dir)],
capture_output=True,
text=True,
)
assert res.returncode == 0
assert junit.exists()
content = junit.read_text()
assert 'name="native-node"' in content
assert 'tests="43"' in content
assert 'test-1' in content
def test_valid_xml_containing_synthetic_secret_not_published_unchanged(tmp_path):
"""Negative control: Valid XML containing a synthetic secret is not published unchanged."""
results_dir = tmp_path / "test-results"
results_dir.mkdir()
junit = results_dir / "browser-junit.xml"
secret_text = "SuperSecretSyntheticToken98765"
xml_with_secret = f"""<testsuites>
<testsuite name="photo-editor" tests="1" failures="1">
<testcase name="auth-test">
<failure message="Encountered password={secret_text} in log">
Stack trace containing synthetic-secret authorization: Bearer {secret_text}
</failure>
<system-out>session_token={secret_text}</system-out>
</testcase>
</testsuite>
</testsuites>"""
junit.write_text(xml_with_secret)
ok = verify_and_sanitize_artifacts(str(results_dir))
assert ok is True
assert junit.exists()
sanitized_content = junit.read_text()
# The raw secret MUST NOT appear anywhere in the published report
assert secret_text not in sanitized_content
# The redaction marker MUST appear
assert "[REDACTED]" in sanitized_content
# The XML must still be structurally valid JUnit XML
tree = ET.parse(junit)
assert tree.getroot().tag == "testsuites"
def test_exact_property_api_key_cannot_appear_unchanged_in_uploaded_report(tmp_path):
"""Regression: <property name="api_key" value="sk-example-123" /> cannot appear unchanged in uploaded report."""
results_dir = tmp_path / "test-results"
results_dir.mkdir()
junit = results_dir / "browser-junit.xml"
xml_with_property = """<testsuites name="photo-editor" tests="1" failures="0">
<testsuite name="brush-engine.spec.js" tests="1" failures="0">
<properties>
<property name="api_key" value="sk-example-123" />
</properties>
<property name="api_key" value="sk-example-123" />
<testcase name="brush presets and shared stroke controls remain reusable" classname="photo-editor/brush-engine.spec.js" time="0.5" />
</testsuite>
</testsuites>"""
junit.write_text(xml_with_property)
ok = verify_and_sanitize_artifacts(str(results_dir))
assert ok is True
assert junit.exists()
sanitized_xml = junit.read_text()
# The exact sensitive property and value MUST NOT appear anywhere in the published report
assert "sk-example-123" not in sanitized_xml
assert "api_key" not in sanitized_xml
assert "<property" not in sanitized_xml
assert "<properties" not in sanitized_xml
# Test identity and structure must remain valid
tree = ET.parse(junit)
root = tree.getroot()
assert root.tag == "testsuites"
cases = list(root.iter("testcase"))
assert len(cases) == 1
assert cases[0].get("name") == "brush presets and shared stroke controls remain reusable"
assert cases[0].get("classname") == "photo-editor/brush-engine.spec.js"
def test_standard_node_and_playwright_reports_remain_valid_and_retain_identities(tmp_path):
"""Standard Node and Playwright JUnit reports remain structurally valid and retain test identities."""
results_dir = tmp_path / "test-results"
results_dir.mkdir()
# 1. Standard Node report representation (similar to node:test runner output)
node_dir = results_dir / "native-node"
node_dir.mkdir()
node_junit = node_dir / "junit.xml"
node_junit.write_text("""<?xml version="1.0" encoding="utf-8"?>
<testsuites>
<testcase name="research cards distinguish live work, handoff, completion and no evidence" time="0.000549" classname="test" file="tests/backgroundToolJobs.test.mjs" />
<testcase name="Chat Photo Editor Action Integrity and Turn Scoping" time="0.499862" classname="test" file="tests/chatPhotoEditorIntegrity.test.mjs" />
</testsuites>""")
# 2. Standard Playwright report representation (with suites, timing, counts, and failure classifications)
browser_junit = results_dir / "browser-junit.xml"
browser_junit.write_text("""<testsuites id="" name="" tests="2" failures="1" skipped="0" errors="0" time="12.5">
<testsuite name="photo-editor/brush-engine.spec.js" timestamp="2026-10-31T00:00:00Z" hostname="chromium" tests="2" failures="1" skipped="0" time="12.5" errors="0">
<properties>
<property name="platform" value="linux" />
<property name="api_key" value="sk-example-123" />
</properties>
<testcase name="brush presets and shared stroke controls remain reusable" classname="photo-editor/brush-engine.spec.js" time="1.2" />
<testcase name="long sampled strokes stay responsive and undo atomically" classname="photo-editor/brush-engine.spec.js" time="2.4">
<failure message="expect(strokeDuration - controlDuration).toBeLessThan(5000)" type="FAILURE">
Error: expect(strokeDuration - controlDuration).toBeLessThan(5000)
at brush-engine.spec.js:84:10
</failure>
<system-out>Captured console stdout dump with session_token=abc12345</system-out>
<system-err>Captured console stderr dump</system-err>
</testcase>
</testsuite>
</testsuites>""")
ok_node = verify_and_sanitize_artifacts(str(node_dir))
assert ok_node is True
ok_browser = verify_and_sanitize_artifacts(str(results_dir))
assert ok_browser is True
# Validate Node report
node_tree = ET.parse(node_junit)
node_cases = list(node_tree.iter("testcase"))
assert len(node_cases) == 2
assert node_cases[0].get("name") == "research cards distinguish live work, handoff, completion and no evidence"
assert node_cases[1].get("name") == "Chat Photo Editor Action Integrity and Turn Scoping"
assert node_cases[0].get("file") == "tests/backgroundToolJobs.test.mjs"
# Validate Playwright report
pw_tree = ET.parse(browser_junit)
pw_root = pw_tree.getroot()
assert pw_root.tag == "testsuites"
assert pw_root.get("tests") == "2"
assert pw_root.get("failures") == "1"
pw_cases = list(pw_tree.iter("testcase"))
assert len(pw_cases) == 2
assert pw_cases[0].get("name") == "brush presets and shared stroke controls remain reusable"
assert pw_cases[1].get("name") == "long sampled strokes stay responsive and undo atomically"
# Verify failure classification is preserved
failures = list(pw_cases[1].iter("failure"))
assert len(failures) == 1
assert "toBeLessThan(5000)" in failures[0].get("message")
assert failures[0].get("type") == "FAILURE"
# Verify properties and system-out/err were pruned
pw_text = browser_junit.read_text()
assert "sk-example-123" not in pw_text
assert "api_key" not in pw_text
assert "<properties" not in pw_text
assert "<property" not in pw_text
assert "<system-out" not in pw_text
assert "<system-err" not in pw_text
assert "session_token" not in pw_text
def test_skipped_element_content_tail_and_attributes_leak_prevented(tmp_path):
"""Regression: <skipped>PRIVATE_VALUE_123</skipped>, attributes, and tails cannot leak sensitive values."""
results_dir = tmp_path / "test-results"
results_dir.mkdir()
junit = results_dir / "browser-junit.xml"
xml_with_skipped = """<testsuites>
<testsuite name="sample" tests="1" skipped="1">
<testcase name="skipped-case" classname="sample">
<skipped message="PRIVATE_VALUE_123" type="UNOFFICIAL_SECRET_456">PRIVATE_VALUE_123</skipped>PRIVATE_VALUE_123
</testcase>
</testsuite>
</testsuites>"""
junit.write_text(xml_with_skipped)
ok = verify_and_sanitize_artifacts(str(results_dir))
assert ok is True
assert junit.exists()
sanitized_xml = junit.read_text()
# Synthetic sensitive values that do not match secret regexes must be absent
assert "PRIVATE_VALUE_123" not in sanitized_xml
assert "UNOFFICIAL_SECRET_456" not in sanitized_xml
# The output must remain valid JUnit XML
tree = ET.parse(junit)
root = tree.getroot()
assert root.tag == "testsuites"
cases = list(root.iter("testcase"))
assert len(cases) == 1
# Testcase identity remains intact
testcase = cases[0]
assert testcase.get("name") == "skipped-case"
assert testcase.get("classname") == "sample"
# Skipped element remains present and classified as skipped
skipped_elems = list(testcase.iter("skipped"))
assert len(skipped_elems) == 1
skipped_elem = skipped_elems[0]
assert skipped_elem.text is None or not skipped_elem.text.strip()
assert skipped_elem.get("message") == "skipped"
assert skipped_elem.get("type") == "skipped"
def test_unrecognized_xml_purged(tmp_path):
"""Negative control: XML reports that are not recognized JUnit reports are purged."""
results_dir = tmp_path / "test-results"
results_dir.mkdir()
# Non-JUnit filename
creds_xml = results_dir / "credentials.xml"
creds_xml.write_text("<credentials><token>secret</token></credentials>")
# Non-JUnit root element
bad_root_xml = results_dir / "junit.xml"
bad_root_xml.write_text("<app_config><password>secret</password></app_config>")
ok = verify_and_sanitize_artifacts(str(results_dir))
# Non-JUnit XML files must be purged
assert not creds_xml.exists()
assert not bad_root_xml.exists()
def test_verifier_failure_exits_nonzero(tmp_path):
"""Negative control: Verifier failure exits nonzero."""
results_dir = tmp_path / "test-results"
results_dir.mkdir()
# Pass an invalid option or target where verification fails
# Create an un-deletable file to force verification failure
bad_file = results_dir / "unauthorized.tmp"
bad_file.write_text("sensitive")
# Run CLI with an invalid directory or simulating failure
res = subprocess.run(
[sys.executable, str(VERIFY_SCRIPT), str(results_dir), "--extra-arg"],
capture_output=True,
text=True,
)
assert res.returncode != 0
def test_failed_artifact_deletion_prevents_authorization(tmp_path, monkeypatch):
"""Negative control: Failed artifact deletion prevents authorization (exits nonzero)."""
results_dir = tmp_path / "test-results"
results_dir.mkdir()
bad_file = results_dir / "leak.har"
bad_file.write_text("network log with tokens")
# Simulate deletion failure
def failing_safe_remove(fp):
return False
monkeypatch.setattr(verify_module, "safe_remove", failing_safe_remove)
ok = verify_and_sanitize_artifacts(str(results_dir))
# When deletion fails, verification MUST return False!
assert ok is False
# CLI invocation must exit nonzero
res = subprocess.run(
[sys.executable, "-c", f"""
import sys
from pathlib import Path
sys.path.insert(0, '{REPO_ROOT}')
import verify_sanitized_artifacts as vsa
vsa.safe_remove = lambda fp: False
ok = vsa.verify_and_sanitize_artifacts('{results_dir}')
sys.exit(0 if ok else 1)
"""],
capture_output=True,
text=True,
)
assert res.returncode != 0
def test_workflow_upload_steps_require_successful_verification():
"""Negative control: Workflow enforces that upload steps execute ONLY if verification succeeded."""
with open(WORKFLOW_PATH, "r", encoding="utf-8") as f:
workflow = yaml.safe_load(f)
jobs = workflow.get("jobs", {})
# 1. Native Node job
node_job = jobs.get("native-node", {})
node_steps = node_job.get("steps", [])
verify_node_step = None
upload_node_step = None
for step in node_steps:
if step.get("id") == "verify-native-results":
verify_node_step = step
if step.get("uses", "").startswith("actions/upload-artifact"):
upload_node_step = step
assert verify_node_step is not None, "verify-native-results step must exist with explicit id"
assert verify_node_step.get("if") == "always()", "verify step must run with if: always()"
assert upload_node_step is not None, "upload step in native-node must exist"
node_upload_if = upload_node_step.get("if", "")
assert "steps.verify-native-results.outcome == 'success'" in node_upload_if, (
f"Node upload step must depend on verify step success; got if: {node_upload_if}"
)
assert node_upload_if != "always()", "Node upload step must not use plain if: always()"
# 2. Browser job
browser_job = jobs.get("browser", {})
browser_steps = browser_job.get("steps", [])
verify_browser_step = None
upload_browser_step = None
for step in browser_steps:
if step.get("id") == "verify-browser-results":
verify_browser_step = step
if step.get("uses", "").startswith("actions/upload-artifact"):
upload_browser_step = step
assert verify_browser_step is not None, "verify-browser-results step must exist with explicit id"
assert verify_browser_step.get("if") == "always()", "verify step must run with if: always()"
assert upload_browser_step is not None, "upload step in browser must exist"
browser_upload_if = upload_browser_step.get("if", "")
assert "steps.verify-browser-results.outcome == 'success'" in browser_upload_if, (
f"Browser upload step must depend on verify step success; got if: {browser_upload_if}"
)
assert browser_upload_if != "always()", "Browser upload step must not use plain if: always()"
def test_fail_closed_abnormal_termination_purges_all_diagnostics(tmp_path, monkeypatch):
"""Abnormal runner termination without disposable fixture purges all diagnostics, keeping JUnit."""
monkeypatch.setenv("CI", "true")
monkeypatch.setenv("GITHUB_ACTIONS", "true")
monkeypatch.delenv("ODYSSEUS_E2E_DISPOSABLE_ARTIFACTS", raising=False)
monkeypatch.delenv("PHOTO_EDITOR_E2E_DATA_DIR", raising=False)
results_dir = tmp_path / "test-results"
results_dir.mkdir()
junit = results_dir / "browser-junit.xml"
junit.write_text('<testsuites><testsuite name="photo-editor" tests="10" failures="1"/></testsuites>')
trace = results_dir / "trace.zip"
trace.write_bytes(b"PK0304 sensitive trace archive")
har = results_dir / "session.har"
har.write_text('{"log": {"entries": ["secret"]}}')
png = results_dir / "failure.png"
png.write_bytes(b"\x89PNG\r\n\x1a\nfake screenshot")
tmp = results_dir / "temp.tmp"
tmp.write_text("in-progress trace")
db = results_dir / "app.db"
db.write_bytes(b"SQLite format 3 database")
ok = verify_and_sanitize_artifacts(str(results_dir))
assert ok is True
# Safe JUnit reporting MUST be preserved
assert junit.exists()
assert 'failures="1"' in junit.read_text()
# All sensitive diagnostics MUST be purged
assert not trace.exists()
assert not har.exists()
assert not png.exists()
assert not tmp.exists()
assert not db.exists()
def test_sanitizes_trace_archive_when_disposable_ci_verified(tmp_path, monkeypatch):
"""When running inside a verified disposable CI fixture, trace archives are sanitized fail-closed."""
env = _setup_disposable_ci(tmp_path)
for k, v in env.items():
monkeypatch.setenv(k, v)
results_dir = tmp_path / "test-results"
results_dir.mkdir()
junit = results_dir / "browser-junit.xml"
junit.write_text('<testsuites><testsuite name="photo-editor" tests="137" failures="0"/></testsuites>')
trace = results_dir / "trace.zip"
with zipfile.ZipFile(trace, "w") as z:
action = {"type": "action", "name": "fill", "params": {"value": "super_secret_password"}}
z.writestr("0-trace.trace", json.dumps(action).encode() + b"\n")
png = results_dir / "failure.png"
png.write_bytes(b"\x89PNG\r\n\x1a\nvalid screenshot payload")
ok = verify_and_sanitize_artifacts(str(results_dir))
assert ok is True
assert junit.exists()
assert trace.exists()
assert png.exists()
with zipfile.ZipFile(trace, "r") as z:
content = json.loads(z.read("0-trace.trace").decode().strip())
assert content["params"]["value"] == "[REDACTED]"
def test_malformed_traces_are_removed(tmp_path, monkeypatch):
"""Negative control: Malformed trace archive is purged fail-closed without affecting JUnit XML."""
env = _setup_disposable_ci(tmp_path)
for k, v in env.items():
monkeypatch.setenv(k, v)
results_dir = tmp_path / "test-results"
results_dir.mkdir()
junit = results_dir / "browser-junit.xml"
junit.write_text('<testsuites><testsuite name="photo-editor" tests="137" failures="1"/></testsuites>')
malformed_trace = results_dir / "trace.zip"
with zipfile.ZipFile(malformed_trace, "w") as z:
z.writestr("0-trace.trace", b'{"type": "action"}\nmalformed not json line\n')
ok = verify_and_sanitize_artifacts(str(results_dir))
assert ok is True
# JUnit XML is preserved
assert junit.exists()
# Malformed archive is purged!
assert not malformed_trace.exists()
def test_fixture_marker_in_target_dir_establishes_ownership(tmp_path, monkeypatch):
"""Fixture proof in target_dir establishes fixture ownership and is purged before upload."""
data_dir = tmp_path / "odysseus-photo-editor-e2e-data"
data_dir.mkdir(parents=True, exist_ok=True)
db_path = data_dir / "app.db"
db_path.write_bytes(b"SQLite format 3\x00")
monkeypatch.setenv("CI", "true")
monkeypatch.setenv("GITHUB_ACTIONS", "true")
monkeypatch.setenv("ODYSSEUS_E2E_DISPOSABLE_ARTIFACTS", "1")
monkeypatch.setenv("PHOTO_EDITOR_E2E_AUTH", "1")
monkeypatch.delenv("PHOTO_EDITOR_E2E_DATA_DIR", raising=False)
results_dir = tmp_path / "test-results"
results_dir.mkdir()
junit = results_dir / "browser-junit.xml"
junit.write_text('<testsuites><testsuite name="photo-editor" tests="137" failures="0"/></testsuites>')
marker_file = results_dir / ".disposable-ci-fixture.json"
marker_file.write_text(json.dumps({
"synthetic": True,
"authenticated": True,
"database": str(db_path),
}))
trace = results_dir / "trace.zip"
with zipfile.ZipFile(trace, "w") as z:
action = {"type": "action", "name": "fill", "params": {"value": "sensitive_val"}}
z.writestr("0-trace.trace", json.dumps(action).encode() + b"\n")
ok = verify_and_sanitize_artifacts(str(results_dir))
assert ok is True
assert junit.exists()
assert trace.exists()
# Marker file itself must be purged so it is never published as an artifact
assert not marker_file.exists()
def test_unhandled_exception_fails_closed_purges_all_non_xml(tmp_path, monkeypatch):
"""Unexpected exception during verification triggers emergency fail-closed purge of non-XML files."""
env = _setup_disposable_ci(tmp_path)
for k, v in env.items():
monkeypatch.setenv(k, v)
results_dir = tmp_path / "test-results"
results_dir.mkdir()
junit = results_dir / "browser-junit.xml"
junit.write_text('<testsuites><testsuite name="photo-editor" tests="137" failures="0"/></testsuites>')
trace = results_dir / "trace.zip"
trace.write_bytes(b"PK0304 trace")
def broken_sanitize(zp):
raise RuntimeError("Simulated unexpected crash in sanitizer")
monkeypatch.setattr(verify_module, "sanitize_trace_archive", broken_sanitize)
ok = verify_and_sanitize_artifacts(str(results_dir))
assert ok is False
# Safe JUnit reporting is preserved
assert junit.exists()
# Sensitive non-XML file is purged fail-closed!
assert not trace.exists()
def test_cli_invocation_returns_cleanly(tmp_path):
"""Running the script as a CLI entrypoint succeeds with exit code 0."""
results_dir = tmp_path / "test-results"
results_dir.mkdir()
junit = results_dir / "junit.xml"
junit.write_text('<testsuites><testsuite name="native-node" tests="43" failures="0"/></testsuites>')
res = subprocess.run(
[sys.executable, str(VERIFY_SCRIPT), str(results_dir)],
capture_output=True,
text=True,
)
assert res.returncode == 0
assert junit.exists()