mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-30 17:52:26 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57970c4519 | ||
|
|
bb654482f3 | ||
|
|
f45724faed | ||
|
|
6fc6095aec | ||
|
|
ef16398c5c | ||
|
|
d237c3fbba | ||
|
|
7fd4e0e9bb |
@@ -256,3 +256,10 @@ 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
|
||||
|
||||
Generated
+16
-34
@@ -6,15 +6,15 @@
|
||||
"": {
|
||||
"name": "odysseus",
|
||||
"devDependencies": {
|
||||
"@antithesishq/bombadil": "^0.7.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@antithesishq/bombadil": "^0.7.10",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"acorn": "8.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@antithesishq/bombadil": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.7.0.tgz",
|
||||
"integrity": "sha512-alJmnphJ/iUoL5mCsnV3DwtajGy/sEQ3NJJCiMhgjqXshSq2BUtAs0vqdXEiiSkB8HbsOX5CLrAcaogYdwfAJg==",
|
||||
"version": "0.7.10",
|
||||
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.7.10.tgz",
|
||||
"integrity": "sha512-71aLKQBB3GNDBQZXU6MCKbt99dl9I+/pjg0EfGD9eCKXDIqpKPXD2rFH6yCJude37RGtnKq6hZFfZhuTp+Tykg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -22,13 +22,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
|
||||
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.1"
|
||||
"playwright": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -50,44 +50,26 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
"playwright-core": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
||||
+7
-3
@@ -9,11 +9,15 @@
|
||||
"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: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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antithesishq/bombadil": "^0.7.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@antithesishq/bombadil": "^0.7.10",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"acorn": "8.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2690,11 +2690,14 @@ 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: auto 0 0 !important;
|
||||
height: 95dvh !important;
|
||||
max-height: 95dvh !important;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,13 +16,6 @@
|
||||
<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'));
|
||||
|
||||
@@ -379,7 +379,6 @@ 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)
|
||||
@@ -392,7 +391,6 @@ 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,8 +267,9 @@ export function makeWindowResizable(content, options = {}) {
|
||||
content.style.maxWidth = 'none';
|
||||
content.style.maxHeight = 'none';
|
||||
const left = parseFloat(content.style.left);
|
||||
if (Number.isFinite(left) && left + w > window.innerWidth - padRight) {
|
||||
content.style.left = Math.max(padLeft, window.innerWidth - padRight - w) + 'px';
|
||||
if (Number.isFinite(left)) {
|
||||
const rightmostLeft = Math.max(padLeft, window.innerWidth - padRight - w);
|
||||
content.style.left = Math.max(padLeft, Math.min(left, rightmostLeft)) + 'px';
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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.
|
||||
|
||||
@@ -18,3 +20,53 @@ 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,28 @@
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
// 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
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);
|
||||
});
|
||||
@@ -14,12 +14,22 @@ from routes.auth_routes import SESSION_COOKIE
|
||||
from tests.helpers.database import disposable_database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_app(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
auth = AuthManager(str(tmp_path / "auth.json"))
|
||||
@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))
|
||||
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
|
||||
|
||||
@@ -133,3 +143,17 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user