mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-30 17:52:26 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9997b2d96d | ||
|
|
0eb6b9eb1d |
@@ -0,0 +1,17 @@
|
||||
"""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')
|
||||
@@ -0,0 +1,46 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
name: Brush diagnostic (non-authoritative)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [test/remediation-brush-diagnostic-ubuntu]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
browser-diagnostic:
|
||||
name: Chromium application regressions
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
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: Compare trace modes and preserve exact threshold
|
||||
shell: bash
|
||||
run: |
|
||||
lscpu | head -30
|
||||
python --version
|
||||
node --version
|
||||
for mode in retain-on-failure off on; do
|
||||
for sample in 1 2 3; do
|
||||
echo "DIAGNOSTIC_MODE=$mode SAMPLE=$sample"
|
||||
BRUSH_DIAGNOSTIC_MODE="$mode-$sample" npx playwright test --config tests/e2e/playwright.config.js --grep 'long sampled strokes' --trace "$mode" --reporter=line || true
|
||||
done
|
||||
done
|
||||
- name: Run normal prefix through brush case
|
||||
shell: bash
|
||||
run: |
|
||||
BRUSH_DIAGNOSTIC_MODE=normal-prefix npx playwright test --config tests/e2e/playwright.config.js tests/e2e/photo-editor/adjustment-layers.spec.js tests/e2e/photo-editor/app-compare-mobile.spec.js tests/e2e/photo-editor/brush-cursor.spec.js tests/e2e/photo-editor/brush-engine.spec.js --grep-invert 'eyedropper and retouch' --reporter=line || true
|
||||
- name: Six-second main-thread stall negative control
|
||||
shell: bash
|
||||
run: |
|
||||
if BRUSH_DIAGNOSTIC_STALL=1 BRUSH_DIAGNOSTIC_MODE=stall npx playwright test --config tests/e2e/playwright.config.js --grep 'long sampled strokes' --trace off --reporter=line; then
|
||||
echo 'ERROR: negative control was not detected'
|
||||
exit 1
|
||||
fi
|
||||
@@ -256,10 +256,3 @@ jobs:
|
||||
env:
|
||||
PYTEST_SHARD: ${{ matrix.shard }}
|
||||
run: python -m pytest -q -rs --durations=25 --shard "$PYTEST_SHARD"
|
||||
|
||||
- name: Playwright mobile layout checks (shard 1/4 only)
|
||||
if: steps.docs-check.outputs.docs_only != 'true' && matrix.shard == '1/4'
|
||||
run: |
|
||||
ODYSSEUS_TEST_PYTHON=python ODYSSEUS_LAYOUT_BROWSER=chromium npx --no-install playwright test \
|
||||
--config tests/e2e/ios-layout.config.cjs \
|
||||
--reporter=line
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
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: Save native test identities and results
|
||||
if: always()
|
||||
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 130
|
||||
- name: Save per-test results and sanitized failure diagnostics
|
||||
if: always()
|
||||
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
|
||||
@@ -0,0 +1,38 @@
|
||||
# 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 original129
|
||||
cases, plus one driver for the native email-reply contract. 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.
|
||||
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.
|
||||
+1
-5
@@ -9,11 +9,7 @@
|
||||
"test:photo-editor": "playwright test --config tests/e2e/playwright.config.js",
|
||||
"test:photo-editor:install": "playwright install chromium firefox webkit",
|
||||
"test:photo-editor:firefox": "PHOTO_EDITOR_E2E_BROWSER=firefox playwright test --config tests/e2e/playwright.config.js",
|
||||
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js",
|
||||
"test:ios-layout": "playwright test --config tests/e2e/ios-layout.config.cjs",
|
||||
"test:ios-layout:chromium": "ODYSSEUS_LAYOUT_BROWSER=chromium playwright test --config tests/e2e/ios-layout.config.cjs",
|
||||
"test:ios-layout:webkit": "ODYSSEUS_LAYOUT_BROWSER=webkit playwright test --config tests/e2e/ios-layout.config.cjs",
|
||||
"test:ios-layout:install": "playwright install chromium webkit"
|
||||
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antithesishq/bombadil": "^0.7.0",
|
||||
|
||||
@@ -2690,14 +2690,11 @@ body.theme-frosted .modal {
|
||||
max-height: 95dvh !important;
|
||||
}
|
||||
|
||||
/* Notes and Documents must respect the same viewport and safe-area
|
||||
top as other mobile full-height sheets. Keep these authoritative rules
|
||||
consistent with 05-documents.css and 14-email.css. */
|
||||
body.doc-view .doc-editor-pane,
|
||||
body.notes-view .notes-pane {
|
||||
inset: var(--sheet-top) 0 0 !important;
|
||||
height: calc(var(--app-height) - var(--sheet-top)) !important;
|
||||
max-height: calc(var(--app-height) - var(--sheet-top)) !important;
|
||||
inset: auto 0 0 !important;
|
||||
height: 95dvh !important;
|
||||
max-height: 95dvh !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<script nonce="{{CSP_NONCE}}">
|
||||
window._odysseusLoadTime = Date.now();
|
||||
// Stops iOS focus-zoom on every field; iOS still allows pinch-zoom, other platforms would not.
|
||||
(function(){
|
||||
var ua = navigator.userAgent || '';
|
||||
var isIOS = /iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
|
||||
var vp = document.querySelector('meta[name="viewport"]');
|
||||
if (isIOS && vp) vp.setAttribute('content', vp.getAttribute('content') + ', maximum-scale=1');
|
||||
})();
|
||||
(function(){
|
||||
try {
|
||||
var t = JSON.parse(localStorage.getItem('odysseus-theme'));
|
||||
|
||||
@@ -5759,20 +5759,7 @@ function _updateBrushCursor(e) {
|
||||
state.cursorEl.style.height = diameterY + 'px';
|
||||
}
|
||||
state.cursorEl.classList.toggle('ge-brush-cursor--pixel', pixelBrush);
|
||||
// Updating an inline style on every move invalidates document-wide :has()
|
||||
// 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 }]);
|
||||
}
|
||||
state.cursorEl.style.transform = `translate3d(${clientX - diameterX / 2}px, ${clientY - diameterY / 2}px, 0)`;
|
||||
if (state.cursorEl.style.display !== 'block') {
|
||||
state.cursorEl.style.display = 'block';
|
||||
}
|
||||
@@ -14074,20 +14061,7 @@ export function closeEditor(options = {}) {
|
||||
const h = state.editorDocClickHandlers.pop();
|
||||
try { document.removeEventListener('click', h); } catch {}
|
||||
}
|
||||
if (_cursorRaf) { cancelAnimationFrame(_cursorRaf); _cursorRaf = 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;
|
||||
}
|
||||
if (state.cursorEl) { state.cursorEl.remove(); state.cursorEl = null; }
|
||||
try { document.getElementById('ge-cursor-layer')?.remove(); } catch {}
|
||||
// Tear down all floating popups + the dock so closing the editor
|
||||
// doesn't leave stale chips/panels behind on top of the gallery.
|
||||
|
||||
@@ -379,6 +379,7 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
|
||||
root.classList.toggle('keyboard-open', keyboardOpen);
|
||||
if (keyboardOpen) root.style.setProperty('--keyboard-viewport-height', vv.height + 'px');
|
||||
else root.style.removeProperty('--keyboard-viewport-height');
|
||||
if (window.scrollY) window.scrollTo(0, 0);
|
||||
};
|
||||
// iOS fires visualViewport resize only once, mid-animation, and no focusin when
|
||||
// the tapped field already had focus (e.g. the composer after a new chat)
|
||||
@@ -391,6 +392,7 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
|
||||
window.visualViewport.addEventListener('scroll', _syncKeyboardViewport);
|
||||
document.addEventListener('focusin', _syncThroughKeyboardAnimation);
|
||||
document.addEventListener('focusout', _syncThroughKeyboardAnimation);
|
||||
window.addEventListener('scroll', () => { if (window.scrollY) window.scrollTo(0, 0); }, { passive: true });
|
||||
|
||||
let _lastVVHeight = window.visualViewport.height;
|
||||
window.visualViewport.addEventListener('resize', function() {
|
||||
|
||||
@@ -267,9 +267,8 @@ export function makeWindowResizable(content, options = {}) {
|
||||
content.style.maxWidth = 'none';
|
||||
content.style.maxHeight = 'none';
|
||||
const left = parseFloat(content.style.left);
|
||||
if (Number.isFinite(left)) {
|
||||
const rightmostLeft = Math.max(padLeft, window.innerWidth - padRight - w);
|
||||
content.style.left = Math.max(padLeft, Math.min(left, rightmostLeft)) + 'px';
|
||||
if (Number.isFinite(left) && left + w > window.innerWidth - padRight) {
|
||||
content.style.left = Math.max(padLeft, window.innerWidth - padRight - w) + 'px';
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
@@ -99,10 +99,8 @@ cleanup. Specifically:
|
||||
`os.environ[...] = ...` that outlives the test.
|
||||
- **Current working directory** - never `chdir` without restoring; never assert
|
||||
against cwd-relative paths like `./data`. Use a temp workspace helper instead.
|
||||
- **Database** - the root `conftest.py` defaults `DATABASE_URL` to a private,
|
||||
worker-owned SQLite file for collection safety and consistent schema across
|
||||
TestClient threads. This fallback is not a per-test data fixture. A test that
|
||||
writes or depends on particular rows must opt
|
||||
- **Database** - the root `conftest.py` defaults `DATABASE_URL` to an in-memory
|
||||
SQLite for collection safety. A test that needs a real file-backed DB must opt
|
||||
in explicitly via `tests.helpers.sqlite_db.make_temp_sqlite` and bind its
|
||||
`SessionLocal` onto the module under test. Do not rely on a persistent
|
||||
on-disk DB existing.
|
||||
|
||||
+1
-14
@@ -12,17 +12,9 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
# File-backed databases remain fixture-owned. Cleanup restores the caller.
|
||||
# Keep the existing generated environment-reference locations stable.
|
||||
_database_environment = pytest.MonkeyPatch()
|
||||
_database_environment.setenv("DATABASE_URL", "sqlite:///:memory:")
|
||||
from tests.helpers.worker_runtime import bootstrap_runtime, configure_runtime
|
||||
_runtime_environment = bootstrap_runtime()
|
||||
# A file keeps the collection schema consistent across TestClient threads;
|
||||
# SingletonThreadPool can evict the populated in-memory main-thread database.
|
||||
# One patcher owns this variable, including restoration of an inherited URL.
|
||||
_database_environment.setenv(
|
||||
"DATABASE_URL",
|
||||
f"sqlite:///{_runtime_environment.root / 'collection.db'}"
|
||||
if _runtime_environment is not None else "sqlite:///:memory:",
|
||||
)
|
||||
_collection_database_engine = None
|
||||
|
||||
# Pre-import real heavy modules BEFORE any test file's module-level stubs can
|
||||
# replace them with MagicMock. Some test files (e.g. test_llm_core_sanitize_*)
|
||||
@@ -34,7 +26,6 @@ try:
|
||||
import sqlalchemy.orm # noqa: F401
|
||||
import core.database # noqa: F401
|
||||
import src.database
|
||||
_collection_database_engine = core.database.engine
|
||||
except ImportError:
|
||||
pass # not installed - the stubs below will handle it
|
||||
|
||||
@@ -109,10 +100,6 @@ def pytest_configure(config):
|
||||
only registers marker names; it imports no production module.
|
||||
"""
|
||||
config.add_cleanup(_database_environment.undo)
|
||||
if _collection_database_engine is not None:
|
||||
# Registered after runtime cleanup, so connections close before the
|
||||
# owned file is removed (also required on Windows).
|
||||
config.add_cleanup(_collection_database_engine.dispose)
|
||||
|
||||
import pathlib
|
||||
from tests._taxonomy import discover_markers
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# Browser End-to-End Tests
|
||||
|
||||
## Photo Editor Release Gate
|
||||
|
||||
The photo editor release gate launches an isolated Odysseus server on port
|
||||
`7013` with a temporary SQLite database and disposable authenticated account.
|
||||
|
||||
@@ -20,53 +18,3 @@ The disposable account is created in the temporary data directory and exercises
|
||||
owner-scoped draft and notification routes without using an existing account.
|
||||
`PHOTO_EDITOR_E2E_AUTH=0` selects the legacy unauthenticated configuration for
|
||||
investigating it; offline-upgrade coverage requires the authenticated mode.
|
||||
|
||||
## Mobile and iOS Layout Suite
|
||||
|
||||
The dedicated mobile and iOS layout suite exercises viewport behavior,
|
||||
accessibility zoom, scrolling, mobile safe-area geometry (Notes and Documents
|
||||
panes), and floating window restoration against an isolated local Odysseus server
|
||||
running on port `17739`.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run test:ios-layout:chromium
|
||||
```
|
||||
|
||||
### Profiles and Browser Prerequisites
|
||||
|
||||
- **Chromium profiles** (`desktop-chromium`, `phone-chromium-portrait`, `phone-chromium-landscape`, `tablet-chromium`):
|
||||
Provide automated baseline coverage in CI and local development without requiring
|
||||
WebKit dependencies.
|
||||
- **WebKit profiles** (`phone-webkit-portrait`, `phone-webkit-landscape`, `tablet-webkit`):
|
||||
Used for platform-oriented WebKit rendering validation. WebKit requires browser binaries:
|
||||
|
||||
```bash
|
||||
npm run test:ios-layout:install
|
||||
# or: npx playwright install webkit
|
||||
npm run test:ios-layout:webkit
|
||||
```
|
||||
|
||||
To run all installed profiles:
|
||||
|
||||
```bash
|
||||
npm run test:ios-layout
|
||||
```
|
||||
|
||||
### Evidence and Cleanup
|
||||
|
||||
- **Evidence artifacts**: Screenshots are written to `test-results/ios-layout/` by
|
||||
default and traces are written to `test-results/ios-layout-traces/`. Both paths
|
||||
are git-ignored. Set `ODYSSEUS_LAYOUT_EVIDENCE_DIR` to override the screenshot
|
||||
output path.
|
||||
- **Teardown**: Disposable application state and database files created in the
|
||||
OS temporary directory are automatically cleaned up on both normal and failure
|
||||
teardown, preserving diagnostic screenshots and failure traces.
|
||||
|
||||
### Platform Considerations
|
||||
|
||||
Emulated Chromium and WebKit viewports validate deterministic CSS cascade,
|
||||
computed layout geometry, viewport meta tags, and JavaScript restoration. However,
|
||||
browser emulation is not a physical device substitute: physical iOS Safari and
|
||||
standalone installed PWAs should still be verified separately for native gesture
|
||||
pinch-zoom, software keyboard dismissal transitions, and dynamic hardware notch/island insets.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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 };
|
||||
@@ -1,28 +0,0 @@
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
/**
|
||||
* Global teardown for the iOS/mobile layout Playwright test suite.
|
||||
* Cleans up only disposable temporary application data directories,
|
||||
* preserving diagnostic artifacts (screenshots and failure traces) in test-results/.
|
||||
*/
|
||||
module.exports = async () => {
|
||||
const dataDirectory = process.env.ODYSSEUS_LAYOUT_DATA_DIR;
|
||||
if (!dataDirectory || typeof dataDirectory !== 'string') return;
|
||||
|
||||
try {
|
||||
const resolvedData = path.resolve(dataDirectory);
|
||||
const allowedTmpBases = [os.tmpdir(), '/var/tmp', '/tmp'].map(b => path.resolve(b));
|
||||
const isTmp = allowedTmpBases.some(base => resolvedData.startsWith(base + path.sep));
|
||||
const baseName = path.basename(resolvedData);
|
||||
|
||||
if (isTmp && (baseName.startsWith('odysseus-ios-layout-') || baseName.startsWith('odysseus-pr6396-'))) {
|
||||
if (fs.existsSync(resolvedData)) {
|
||||
fs.rmSync(resolvedData, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[ios-layout-teardown] Warning: failed to clean temporary runtime dir: ${err.message}`);
|
||||
}
|
||||
};
|
||||
@@ -1,148 +0,0 @@
|
||||
// Tests against the running Odysseus app, not a static mock page.
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
|
||||
const repositoryRoot = path.resolve(__dirname, '../..');
|
||||
const port = Number(process.env.ODYSSEUS_LAYOUT_PORT || '17739');
|
||||
const baseURL = `http://127.0.0.1:${port}`;
|
||||
const defaultPython = process.platform === 'win32'
|
||||
? path.join(repositoryRoot, '.venv', 'Scripts', 'python.exe')
|
||||
: path.join(repositoryRoot, '.venv', 'bin', 'python');
|
||||
const python = process.env.ODYSSEUS_TEST_PYTHON
|
||||
|| (fs.existsSync(defaultPython) ? defaultPython : 'python3');
|
||||
|
||||
const dataDirectory = process.env.ODYSSEUS_LAYOUT_DATA_DIR
|
||||
|| fs.mkdtempSync(path.join(os.tmpdir(), 'odysseus-ios-layout-'));
|
||||
process.env.ODYSSEUS_LAYOUT_DATA_DIR = dataDirectory;
|
||||
|
||||
// Default evidence directory to repository-relative test-results/ios-layout
|
||||
// while preserving the environment-variable override if provided.
|
||||
const evidenceDir = process.env.ODYSSEUS_LAYOUT_EVIDENCE_DIR
|
||||
|| path.join(repositoryRoot, 'test-results', 'ios-layout');
|
||||
const outputDir = path.join(repositoryRoot, 'test-results', 'ios-layout-traces');
|
||||
|
||||
// Emergency process-exit cleanup for disposable application runtime state
|
||||
const cleanDataDirSync = () => {
|
||||
const dir = process.env.ODYSSEUS_LAYOUT_DATA_DIR;
|
||||
if (!dir || typeof dir !== 'string') return;
|
||||
try {
|
||||
const resolved = path.resolve(dir);
|
||||
const allowed = [os.tmpdir(), '/var/tmp', '/tmp'].map(b => path.resolve(b));
|
||||
if (allowed.some(base => resolved.startsWith(base + path.sep))) {
|
||||
const baseName = path.basename(resolved);
|
||||
if (baseName.startsWith('odysseus-ios-layout-') || baseName.startsWith('odysseus-pr6396-')) {
|
||||
if (fs.existsSync(resolved)) {
|
||||
fs.rmSync(resolved, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
process.once('SIGINT', () => { cleanDataDirSync(); process.exit(130); });
|
||||
process.once('SIGTERM', () => { cleanDataDirSync(); process.exit(143); });
|
||||
|
||||
function isWebKitInstalled() {
|
||||
try {
|
||||
const { webkit } = require('@playwright/test');
|
||||
return fs.existsSync(webkit.executablePath());
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const chromiumProjects = [
|
||||
{
|
||||
name: 'desktop-chromium',
|
||||
use: { browserName: 'chromium', viewport: { width: 1440, height: 900 } },
|
||||
},
|
||||
{
|
||||
name: 'phone-chromium-portrait',
|
||||
use: { browserName: 'chromium', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true },
|
||||
},
|
||||
{
|
||||
name: 'phone-chromium-landscape',
|
||||
use: { browserName: 'chromium', viewport: { width: 844, height: 390 }, hasTouch: true, isMobile: true },
|
||||
},
|
||||
{
|
||||
name: 'tablet-chromium',
|
||||
use: { browserName: 'chromium', viewport: { width: 820, height: 1000 }, hasTouch: true, isMobile: true },
|
||||
},
|
||||
];
|
||||
|
||||
const webkitProjects = [
|
||||
{
|
||||
name: 'phone-webkit-portrait',
|
||||
use: { browserName: 'webkit', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true },
|
||||
},
|
||||
{
|
||||
name: 'phone-webkit-landscape',
|
||||
use: { browserName: 'webkit', viewport: { width: 844, height: 390 }, hasTouch: true, isMobile: true },
|
||||
},
|
||||
{
|
||||
name: 'tablet-webkit',
|
||||
use: { browserName: 'webkit', viewport: { width: 820, height: 1000 }, hasTouch: true, isMobile: true },
|
||||
},
|
||||
];
|
||||
|
||||
// Determine active projects based on environment and local browser availability
|
||||
const requestedBrowser = (process.env.ODYSSEUS_LAYOUT_BROWSER || 'all').toLowerCase();
|
||||
let selectedProjects = [];
|
||||
|
||||
if (requestedBrowser === 'chromium') {
|
||||
selectedProjects = chromiumProjects;
|
||||
} else if (requestedBrowser === 'webkit') {
|
||||
selectedProjects = webkitProjects;
|
||||
} else {
|
||||
// 'all': Include chromium always; include webkit if installed or if explicitly targeted via cli args
|
||||
const hasCliWebKitTarget = process.argv.some(arg => arg.toLowerCase().includes('webkit'));
|
||||
if (isWebKitInstalled() || hasCliWebKitTarget) {
|
||||
selectedProjects = [...chromiumProjects, ...webkitProjects];
|
||||
} else {
|
||||
// Only Chromium is available locally (e.g. standard CI / default runner)
|
||||
selectedProjects = chromiumProjects;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: path.join(__dirname, 'ios-layout'),
|
||||
testMatch: ['**/*.spec.cjs'],
|
||||
outputDir,
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 15_000 },
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: [['line']],
|
||||
globalTeardown: require.resolve('./ios-layout-teardown.cjs'),
|
||||
use: {
|
||||
baseURL,
|
||||
headless: true,
|
||||
serviceWorkers: 'block',
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
projects: selectedProjects,
|
||||
metadata: { evidenceDir },
|
||||
webServer: {
|
||||
command: `${JSON.stringify(python)} -m uvicorn app:app --host 127.0.0.1 --port ${port}`,
|
||||
cwd: repositoryRoot,
|
||||
url: baseURL,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: false,
|
||||
env: {
|
||||
...process.env,
|
||||
AUTH_ENABLED: 'false',
|
||||
// Never connect the disposable app to the host's real ChromaDB.
|
||||
CHROMADB_HOST: '127.0.0.1',
|
||||
CHROMADB_PORT: String(port + 1),
|
||||
LLM_HOST: '127.0.0.1',
|
||||
EMBEDDING_URL: `http://127.0.0.1:${port + 1}/v1/embeddings`,
|
||||
DATABASE_URL: `sqlite:///${path.join(dataDirectory, 'app.db')}`,
|
||||
ODYSSEUS_DATA_DIR: dataDirectory,
|
||||
ODYSSEUS_STARTUP_WARMUPS: '0',
|
||||
RESPONSE_COMPRESSION_ENABLED: 'false',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,313 +0,0 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
async function openApp(page) {
|
||||
const response = await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
expect(response?.ok()).toBeTruthy();
|
||||
// The runtime currently produces two #message nodes. Select the
|
||||
// initialized composer while diagnosing whether both are visible.
|
||||
const composers = await page.locator('textarea#message').evaluateAll(nodes =>
|
||||
nodes.map((el, index) => ({
|
||||
index,
|
||||
initialized: el.dataset.startupPreserveBound === '1',
|
||||
visible: !!el.getClientRects().length &&
|
||||
getComputedStyle(el).visibility !== 'hidden',
|
||||
parent: el.parentElement?.className || '',
|
||||
}))
|
||||
);
|
||||
if (composers.length !== 1) {
|
||||
console.log('Composer DOM diagnostic:', JSON.stringify(composers));
|
||||
}
|
||||
const activeComposer = page.locator(
|
||||
'textarea#message[data-startup-preserve-bound="1"]'
|
||||
);
|
||||
await expect(activeComposer).toHaveCount(1);
|
||||
await expect(activeComposer).toBeVisible();
|
||||
expect(
|
||||
composers.filter(composer => composer.visible).length,
|
||||
'Exactly one composer should be visible'
|
||||
).toBe(1);
|
||||
}
|
||||
|
||||
function isMobileViewport(testInfo) {
|
||||
const width = testInfo.project.use?.viewport?.width || 0;
|
||||
return width > 0 && width <= 768;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reach Settings through genuine UI controls on either desktop or mobile viewports.
|
||||
* On mobile (<=768px), the rail is hidden and the sidebar must be opened via hamburger.
|
||||
*/
|
||||
async function openSettings(page, isMobile) {
|
||||
let settingsButton;
|
||||
if (isMobile) {
|
||||
const hamburger = page.locator('#hamburger-btn:visible').first();
|
||||
await expect(hamburger, 'Hamburger button must be visible on mobile').toBeVisible({ timeout: 5000 });
|
||||
await hamburger.click();
|
||||
const sidebar = page.locator('#sidebar:not(.hidden)');
|
||||
await expect(sidebar, 'Sidebar must open upon hamburger tap').toBeVisible({ timeout: 5000 });
|
||||
await page.waitForTimeout(300);
|
||||
settingsButton = sidebar.locator('#user-bar-settings');
|
||||
} else {
|
||||
settingsButton = page.locator('#rail-settings:visible, #user-bar-settings:visible').first();
|
||||
}
|
||||
|
||||
await expect(settingsButton, 'Settings button must be reachable and visible').toBeVisible({ timeout: 5000 });
|
||||
await settingsButton.click();
|
||||
const dialog = page.locator('#settings-modal');
|
||||
await expect(dialog, 'Settings modal must become visible').toBeVisible({ timeout: 5000 });
|
||||
return dialog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reach Notes through genuine UI controls on either desktop or mobile viewports.
|
||||
*/
|
||||
async function openNotes(page, isMobile) {
|
||||
let notesButton;
|
||||
if (isMobile) {
|
||||
const hamburger = page.locator('#hamburger-btn:visible').first();
|
||||
await expect(hamburger, 'Hamburger button must be visible on mobile').toBeVisible({ timeout: 5000 });
|
||||
await hamburger.click();
|
||||
const sidebar = page.locator('#sidebar:not(.hidden)');
|
||||
await expect(sidebar, 'Sidebar must open upon hamburger tap').toBeVisible({ timeout: 5000 });
|
||||
await page.waitForTimeout(300);
|
||||
notesButton = sidebar.locator('#tool-notes-btn');
|
||||
} else {
|
||||
notesButton = page.locator('#rail-notes:visible, #tool-notes-btn:visible').first();
|
||||
}
|
||||
|
||||
await expect(notesButton, 'Notes button must be reachable and visible').toBeVisible({ timeout: 5000 });
|
||||
await notesButton.click();
|
||||
const realNotes = page.locator('.notes-pane').first();
|
||||
await expect(realNotes, 'Notes pane must become visible').toBeVisible({ timeout: 5000 });
|
||||
return realNotes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reach Document editor through genuine UI controls on either desktop or mobile viewports.
|
||||
*/
|
||||
async function openDocument(page, isMobile) {
|
||||
let docButton;
|
||||
if (isMobile) {
|
||||
const hamburger = page.locator('#hamburger-btn:visible').first();
|
||||
await expect(hamburger, 'Hamburger button must be visible on mobile').toBeVisible({ timeout: 5000 });
|
||||
await hamburger.click();
|
||||
const sidebar = page.locator('#sidebar:not(.hidden)');
|
||||
await expect(sidebar, 'Sidebar must open upon hamburger tap').toBeVisible({ timeout: 5000 });
|
||||
await page.waitForTimeout(300);
|
||||
docButton = sidebar.locator('#library-new-doc-btn');
|
||||
} else {
|
||||
docButton = page.locator('#library-new-doc-btn:visible').first();
|
||||
if (!(await docButton.count())) {
|
||||
const hamburger = page.locator('#hamburger-btn:visible').first();
|
||||
if (await hamburger.count()) {
|
||||
await hamburger.click();
|
||||
await page.waitForTimeout(300);
|
||||
docButton = page.locator('#sidebar:not(.hidden) #library-new-doc-btn');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await expect(docButton, 'Document creation button must be reachable and visible').toBeVisible({ timeout: 5000 });
|
||||
await docButton.click();
|
||||
const docPane = page.locator('.doc-editor-pane').first();
|
||||
await expect(docPane, 'Document editor pane must become visible').toBeVisible({ timeout: 10000 });
|
||||
return docPane;
|
||||
}
|
||||
|
||||
test('renders actual Odysseus UI and captures review screenshots', async ({ page }, testInfo) => {
|
||||
await openApp(page);
|
||||
const out = testInfo.config.metadata.evidenceDir;
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const name = testInfo.project.name;
|
||||
const isMobile = isMobileViewport(testInfo);
|
||||
|
||||
// 1. Chat screenshot (default view)
|
||||
await page.screenshot({ path: path.join(out, `${name}-chat.png`), fullPage: false, animations: 'disabled' });
|
||||
|
||||
// 2. Settings screenshot via actual user-visible navigation
|
||||
await openApp(page);
|
||||
await openSettings(page, isMobile);
|
||||
await page.screenshot({ path: path.join(out, `${name}-settings.png`), fullPage: false, animations: 'disabled' });
|
||||
|
||||
// 3. Notes screenshot via actual user-visible navigation
|
||||
await openApp(page);
|
||||
await openNotes(page, isMobile);
|
||||
await page.screenshot({ path: path.join(out, `${name}-notes.png`), fullPage: false, animations: 'disabled' });
|
||||
|
||||
testInfo.annotations.push({ type: 'evidence', description: out });
|
||||
});
|
||||
|
||||
test('viewport meta does not restrict user zoom', async ({ page }) => {
|
||||
await openApp(page);
|
||||
const meta = await page.locator('meta[name="viewport"]').getAttribute('content');
|
||||
expect(meta || '').not.toMatch(/maximum-scale\s*=\s*1(?:\b|\.)/i);
|
||||
expect(meta || '').not.toMatch(/user-scalable\s*=\s*no/i);
|
||||
});
|
||||
|
||||
test('browser-owned window scrolling is not globally reset', async ({ page }) => {
|
||||
await openApp(page);
|
||||
const value = await page.evaluate(async () => {
|
||||
document.documentElement.style.overflowY = 'auto';
|
||||
document.body.style.overflowY = 'auto';
|
||||
document.body.style.height = 'auto';
|
||||
document.body.style.minHeight = '3200px';
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.cssText = 'position:absolute;top:3100px;width:1px;height:10px;pointer-events:none;';
|
||||
document.body.appendChild(spacer);
|
||||
window.scrollTo(0, 250);
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
return window.scrollY;
|
||||
});
|
||||
expect(value).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
test('CSS cascade rules for mobile Notes and Documents respect safe areas', async ({ page }, testInfo) => {
|
||||
test.skip(!isMobileViewport(testInfo), 'Only mobile viewports <=768px');
|
||||
await openApp(page);
|
||||
const results = await page.evaluate(() => {
|
||||
document.documentElement.style.setProperty('--safe-top', '44px');
|
||||
document.documentElement.style.setProperty('--sheet-top', '68px');
|
||||
document.documentElement.style.setProperty('--app-height', '844px');
|
||||
const values = {};
|
||||
for (const [key, bodyClass, paneClass] of [
|
||||
['notes', 'notes-view', 'notes-pane'],
|
||||
['document', 'doc-view', 'doc-editor-pane'],
|
||||
]) {
|
||||
const pane = document.createElement('section');
|
||||
pane.className = paneClass;
|
||||
pane.style.opacity = '0'; // CSS cascade regression check
|
||||
document.body.appendChild(pane);
|
||||
document.body.classList.add(bodyClass);
|
||||
const style = window.getComputedStyle(pane);
|
||||
values[key] = {
|
||||
top: style.top,
|
||||
height: style.height,
|
||||
insetTop: pane.getBoundingClientRect().top,
|
||||
};
|
||||
pane.remove();
|
||||
document.body.classList.remove(bodyClass);
|
||||
}
|
||||
return values;
|
||||
});
|
||||
for (const key of ['notes', 'document']) {
|
||||
expect(results[key].top, key).toBe('68px');
|
||||
expect(parseFloat(results[key].height), key).toBeGreaterThanOrEqual(770);
|
||||
}
|
||||
});
|
||||
|
||||
test('real Notes and Document panes honor safe-area geometry in the live application', async ({ page }, testInfo) => {
|
||||
const isMobile = isMobileViewport(testInfo);
|
||||
test.skip(!isMobile, 'Only mobile viewports <=768px');
|
||||
await openApp(page);
|
||||
|
||||
// Configure simulated safe-area insets (e.g. iPhone notch / Dynamic Island)
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.setProperty('--safe-top', '44px');
|
||||
document.documentElement.style.setProperty('--sheet-top', '68px');
|
||||
document.documentElement.style.setProperty('--app-height', '844px');
|
||||
});
|
||||
|
||||
// 1. Verify actual Notes UI
|
||||
const realNotes = await openNotes(page, isMobile);
|
||||
const notesMetrics = await realNotes.evaluate(el => {
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
top: style.top,
|
||||
height: style.height,
|
||||
maxHeight: style.maxHeight,
|
||||
rectTop: rect.top,
|
||||
rectHeight: rect.height,
|
||||
};
|
||||
});
|
||||
|
||||
expect(notesMetrics.top, 'Real Notes pane top must match --sheet-top').toBe('68px');
|
||||
expect(parseFloat(notesMetrics.height), 'Real Notes pane height must respect safe area').toBeGreaterThanOrEqual(770);
|
||||
expect(notesMetrics.rectTop, 'Real Notes pane must not overlap the 44px safe-area top notch').toBeGreaterThanOrEqual(44);
|
||||
|
||||
// 2. Verify actual Document UI
|
||||
await openApp(page);
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.setProperty('--safe-top', '44px');
|
||||
document.documentElement.style.setProperty('--sheet-top', '68px');
|
||||
document.documentElement.style.setProperty('--app-height', '844px');
|
||||
});
|
||||
|
||||
const realDoc = await openDocument(page, isMobile);
|
||||
const docMetrics = await realDoc.evaluate(el => {
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
top: style.top,
|
||||
height: style.height,
|
||||
maxHeight: style.maxHeight,
|
||||
rectTop: rect.top,
|
||||
rectHeight: rect.height,
|
||||
};
|
||||
});
|
||||
|
||||
expect(docMetrics.top, 'Real Document pane top must match --sheet-top').toBe('68px');
|
||||
expect(parseFloat(docMetrics.height), 'Real Document pane height must respect safe area').toBeGreaterThanOrEqual(770);
|
||||
expect(docMetrics.rectTop, 'Real Document pane must not overlap the 44px safe-area top notch').toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
|
||||
test('restored floating windows clamp away from a left safe area', async ({ page }, testInfo) => {
|
||||
test.skip(isMobileViewport(testInfo), 'Geometry test uses desktop resizable windows');
|
||||
await openApp(page);
|
||||
const value = await page.evaluate(async () => {
|
||||
document.documentElement.style.setProperty('--safe-left', '58px');
|
||||
document.body.style.setProperty('padding-left', '58px', 'important');
|
||||
const probe = document.createElement('div');
|
||||
probe.style.cssText = 'position:fixed;left:10px;top:80px;width:420px;height:250px;z-index:999999;';
|
||||
document.body.appendChild(probe);
|
||||
const key = 'odysseus-layout-restore-test';
|
||||
localStorage.setItem(key, JSON.stringify({ w: 480, h: 280 }));
|
||||
try {
|
||||
// Body horizontal padding transitions over 160ms. Wait for the
|
||||
// simulated safe-area change to settle before restoring geometry.
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
const padAtRestore = parseFloat(
|
||||
getComputedStyle(document.body).paddingLeft
|
||||
);
|
||||
|
||||
if (Math.abs(padAtRestore - 58) > 0.5) {
|
||||
throw new Error(
|
||||
`Safe-area fixture did not settle: ${padAtRestore}px`
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the running server actually serves the patched module.
|
||||
const response = await fetch(
|
||||
'/static/js/windowResize.js',
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error('Cannot fetch windowResize.js');
|
||||
}
|
||||
const moduleSource = await response.text();
|
||||
if (!moduleSource.includes('const rightmostLeft = Math.max(padLeft')) {
|
||||
throw new Error('Running server is serving unpatched windowResize.js');
|
||||
}
|
||||
|
||||
const { makeWindowResizable } = await import('/static/js/windowResize.js');
|
||||
makeWindowResizable(probe, { storageKey: key, mobileSkip: 768 });
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
return {
|
||||
left: parseFloat(probe.style.left),
|
||||
width: parseFloat(probe.style.width),
|
||||
paddingLeft: parseFloat(getComputedStyle(document.body).paddingLeft),
|
||||
viewportWidth: window.innerWidth,
|
||||
};
|
||||
} finally {
|
||||
localStorage.removeItem(key);
|
||||
probe.remove();
|
||||
}
|
||||
});
|
||||
|
||||
expect(value.paddingLeft).toBeGreaterThanOrEqual(58);
|
||||
expect(value.width).toBe(480);
|
||||
expect(value.left).toBeGreaterThanOrEqual(value.paddingLeft);
|
||||
});
|
||||
@@ -46,475 +46,3 @@ test('brush cursor matches the rendered brush diameter and replaces the crosshai
|
||||
await expect.poll(async () => (await cursor.boundingBox()).width)
|
||||
.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');
|
||||
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(() => {
|
||||
return {
|
||||
cursorCount: document.querySelectorAll('.ge-brush-cursor').length,
|
||||
layerCount: document.querySelectorAll('#ge-cursor-layer').length,
|
||||
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.activeAnims).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
test('long sampled strokes stay responsive and undo atomically', async ({ page }) => {
|
||||
test('long sampled strokes stay responsive and undo atomically', async ({ page, browserName }, testInfo) => {
|
||||
await openBlankEditor(page);
|
||||
await selectTool(page, 'brush');
|
||||
await page.locator('.ge-size-slider').fill('700');
|
||||
@@ -49,13 +49,45 @@ test('long sampled strokes stay responsive and undo atomically', async ({ page }
|
||||
const before = await flattenedPixelDigest(page);
|
||||
const start = await canvasPoint(page, 0.08, 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 metricSnapshot = async () => Object.fromEntries((await metrics.send('Performance.getMetrics')).metrics.filter(x => ['TaskDuration','ScriptDuration','LayoutDuration','RecalcStyleDuration'].includes(x.name)).map(x => [x.name, x.value * 1000]));
|
||||
const beforeMetrics = metrics ? await metricSnapshot() : {};
|
||||
const beforeTask = metrics ? await taskTime() : null;
|
||||
const startedAt = Date.now();
|
||||
await page.mouse.move(start.x, start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(end.x, end.y, { steps: 240 });
|
||||
await page.mouse.up();
|
||||
const strokeDuration = Date.now() - startedAt;
|
||||
expect(strokeDuration).toBeLessThan(5000);
|
||||
if (process.env.BRUSH_DIAGNOSTIC_STALL === '1') await page.evaluate(() => { const until = performance.now() + 6000; while (performance.now() < until) {} });
|
||||
const mainThreadDuration = metrics ? (await taskTime()) - beforeTask : null;
|
||||
const afterMetrics = metrics ? await metricSnapshot() : {};
|
||||
console.log('BRUSH_DIAGNOSTIC ' + JSON.stringify({mode: process.env.BRUSH_DIAGNOSTIC_MODE, samples: 240, strokeDuration, controlDuration, mainThreadDuration, breakdown: Object.fromEntries(Object.keys(beforeMetrics).map(k => [k, afterMetrics[k] - beforeMetrics[k]]))}));
|
||||
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);
|
||||
expect(after).not.toEqual(before);
|
||||
await expect(thumb).toHaveAttribute('width', '68');
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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);
|
||||
});
|
||||
@@ -2,6 +2,12 @@ const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
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 baseURL = `http://127.0.0.1:${port}`;
|
||||
@@ -24,7 +30,9 @@ if (!['chromium', 'firefox', 'webkit'].includes(browserName)) {
|
||||
throw new Error(`Unsupported PHOTO_EDITOR_E2E_BROWSER: ${browserName}`);
|
||||
}
|
||||
|
||||
const isCI = Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS);
|
||||
if (disposableOptIn) {
|
||||
fs.writeFileSync(path.join(dataDirectory, marker), JSON.stringify({ synthetic: true, authenticated: true, database: databasePath }));
|
||||
}
|
||||
const traceOptIn = !isCI && ['1', 'true'].includes(String(process.env.PLAYWRIGHT_ENABLE_TRACE || '').toLowerCase());
|
||||
|
||||
module.exports = defineConfig({
|
||||
@@ -32,6 +40,8 @@ module.exports = defineConfig({
|
||||
outputDir: path.join(__dirname, '..', '..', 'test-results', 'photo-editor'),
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 10_000 },
|
||||
forbidOnly: isCI,
|
||||
retries: 0,
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
reporter: isCI ? 'line' : 'line',
|
||||
@@ -49,9 +59,9 @@ module.exports = defineConfig({
|
||||
serviceWorkers: 'block',
|
||||
...(authenticated ? { storageState: path.join(dataDirectory, 'browser-auth.json') } : {}),
|
||||
screenshot: 'only-on-failure',
|
||||
// Unconditional CI trace suppression: Playwright traces are NEVER recorded in CI.
|
||||
// Default CI suppression remains; only the fresh synthetic fixture opts in.
|
||||
// Local developer tracing requires explicit PLAYWRIGHT_ENABLE_TRACE=1.
|
||||
trace: traceOptIn ? 'retain-on-failure' : 'off',
|
||||
trace: (traceOptIn || disposableCIArtifacts()) ? 'retain-on-failure' : 'off',
|
||||
},
|
||||
webServer: {
|
||||
command: `${JSON.stringify(python)} -m uvicorn app:app --host 127.0.0.1 --port ${port}`,
|
||||
@@ -65,6 +75,10 @@ module.exports = defineConfig({
|
||||
DATABASE_URL: `sqlite:///${databasePath}`,
|
||||
ODYSSEUS_DATA_DIR: dataDirectory,
|
||||
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',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { disposableCIArtifacts } = require('./artifact-policy');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||
const defaultResultsDir = path.join(repoRoot, 'test-results');
|
||||
@@ -29,6 +30,9 @@ module.exports = async (options = {}) => {
|
||||
const isPlaywrightConfig = options && (options.configFile || options.rootDir);
|
||||
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:
|
||||
// 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;
|
||||
@@ -100,7 +104,7 @@ module.exports = async (options = {}) => {
|
||||
const isCI = Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS);
|
||||
const traceOptIn = !isCI && ['1', 'true'].includes(String(process.env.PLAYWRIGHT_ENABLE_TRACE || '').toLowerCase());
|
||||
|
||||
if (isCI || !traceOptIn) {
|
||||
if ((isCI && !disposableCI) || (!traceOptIn && !disposableCI)) {
|
||||
// In CI or default local runs, purge all sensitive trace archives and artifacts
|
||||
purgeSensitiveArtifacts(resultsDir);
|
||||
} else {
|
||||
@@ -124,7 +128,8 @@ module.exports = async (options = {}) => {
|
||||
|
||||
if (!sanitizerSucceeded) {
|
||||
// Fail closed: purge all remaining zip or tmp archives
|
||||
purgeZipAndTmp(resultsDir);
|
||||
if (disposableCI) purgeSensitiveArtifacts(resultsDir);
|
||||
else purgeZipAndTmp(resultsDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -14,22 +14,12 @@ from routes.auth_routes import SESSION_COOKIE
|
||||
from tests.helpers.database import disposable_database
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def auth_configuration(tmp_path_factory):
|
||||
"""Hash real fixture passwords once; only immutable bytes are reused."""
|
||||
path = tmp_path_factory.mktemp("admin-auth-seed") / "auth.json"
|
||||
auth = AuthManager(str(path))
|
||||
@pytest.fixture
|
||||
def authenticated_app(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
auth = AuthManager(str(tmp_path / "auth.json"))
|
||||
for user, admin in (("member", False), ("admin", True)):
|
||||
assert auth.create_user(user, "disposable-test-password", is_admin=admin)
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_app(tmp_path, monkeypatch, auth_configuration):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
path = tmp_path / "auth.json"
|
||||
path.write_bytes(auth_configuration)
|
||||
auth = AuthManager(str(path))
|
||||
app = FastAPI()
|
||||
app.state.auth_manager = auth
|
||||
|
||||
@@ -143,17 +133,3 @@ def test_diagnostics_require_real_admin_before_sensitive_work(
|
||||
for operation in (stats, rag.get_stats, research.call_research_service,
|
||||
extract_id, transcript, collect, read_log):
|
||||
operation.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("instance", [0, 1])
|
||||
def test_authenticated_fixture_has_private_users_sessions_and_file(authenticated_app, instance):
|
||||
app, client_for = authenticated_app
|
||||
auth = app.state.auth_manager
|
||||
assert set(auth.users) == {"member", "admin"}
|
||||
assert not auth._sessions
|
||||
with client_for("member") as client:
|
||||
assert auth.get_username_for_token(client.cookies.get(SESSION_COOKIE)) == "member"
|
||||
assert auth.create_user("fixture-local", "disposable-test-password")
|
||||
# Both parameter cases mutate their own file. Any shared manager, mutable
|
||||
# users/sessions or written-back seed makes the next case fail, in any order.
|
||||
assert "fixture-local" in auth.users
|
||||
|
||||
@@ -85,9 +85,6 @@ def test_actual_tests_restore_process_state_and_ignore_inherited_database(tmp_pa
|
||||
import os
|
||||
import sys
|
||||
import pytest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
def snapshot():
|
||||
import core
|
||||
@@ -110,11 +107,7 @@ def test_actual_tests_restore_process_state_and_ignore_inherited_database(tmp_pa
|
||||
class StateGuard:
|
||||
def pytest_sessionstart(self):
|
||||
self.initial = snapshot()
|
||||
assert self.initial[0] != inherited_url
|
||||
self.default_path = Path(make_url(self.initial[0]).database)
|
||||
assert self.default_path.is_file()
|
||||
assert self.default_path.name == "collection.db"
|
||||
assert self.default_path.parent == Path(tempfile.gettempdir()).parent
|
||||
assert self.initial[0] == "sqlite:///:memory:"
|
||||
|
||||
def pytest_collection_finish(self):
|
||||
assert snapshot() == self.initial, "collection changed database bindings"
|
||||
@@ -125,11 +118,9 @@ def test_actual_tests_restore_process_state_and_ignore_inherited_database(tmp_pa
|
||||
assert snapshot() == self.initial, "test leaked database or module state"
|
||||
|
||||
inherited_url = os.environ["DATABASE_URL"]
|
||||
guard = StateGuard()
|
||||
result = pytest.main(["-q", "-p", "no:cacheprovider", *sys.argv[1:]],
|
||||
plugins=[guard])
|
||||
plugins=[StateGuard()])
|
||||
assert os.environ["DATABASE_URL"] == inherited_url
|
||||
assert not guard.default_path.exists(), "collection database outlived its runtime"
|
||||
raise SystemExit(result)
|
||||
''')
|
||||
result = subprocess.run(
|
||||
|
||||
@@ -303,3 +303,52 @@ def test_teardown_rejects_unconfined_programmatic_paths():
|
||||
res = _run_teardown(options={"resultsDir": "/etc"})
|
||||
assert res.returncode == 1
|
||||
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()
|
||||
|
||||
@@ -79,32 +79,3 @@ def test_tmp_path_under_private_runtime_fits_unix_socket(tmp_path):
|
||||
# witness. A nested pytest-of-<user> basetemp made it 110 bytes under xdist.
|
||||
with socket.socket(socket.AF_UNIX) as sock:
|
||||
sock.bind(str(tmp_path / "tmux.sock"))
|
||||
|
||||
|
||||
def test_collection_database_survives_thread_connection_churn():
|
||||
"""TestClient threads must not lose the collection database or its schema.
|
||||
|
||||
SingletonThreadPool's default five-connection limit used to close the main
|
||||
thread's in-memory database after repeated route clients. Each new thread
|
||||
also saw an empty database. Exercise more threads than that limit and then
|
||||
verify both compatibility factories still see all registered tables.
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from sqlalchemy import inspect
|
||||
import core.database as database
|
||||
import src.database as compatibility
|
||||
|
||||
expected = set(database.Base.metadata.tables)
|
||||
|
||||
def table_names(factory):
|
||||
with factory() as session:
|
||||
return set(inspect(session.connection()).get_table_names())
|
||||
|
||||
assert {"documents", "model_endpoints", "mcp_servers", "calendars"} <= expected
|
||||
assert expected <= table_names(database.SessionLocal)
|
||||
for _ in range(8):
|
||||
# A fresh executor creates a fresh thread rather than reusing one.
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
assert expected <= executor.submit(table_names, database.SessionLocal).result()
|
||||
assert expected <= table_names(database.SessionLocal)
|
||||
assert expected <= table_names(compatibility.SessionLocal)
|
||||
|
||||
Reference in New Issue
Block a user