mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-01 02:32:21 +01:00
Merge remote-tracking branch 'origin/dev' into test/remediation-authoritative-native-ci
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -46,3 +46,480 @@ 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');
|
||||
window.__lastCycleAnim = el?._positionAnimation;
|
||||
return {
|
||||
cursorCount: document.querySelectorAll('.ge-brush-cursor').length,
|
||||
layerCount: document.querySelectorAll('#ge-cursor-layer').length,
|
||||
hasAnim: Boolean(el?._positionAnimation),
|
||||
animPaused: el?._positionAnimation?.playState === 'paused',
|
||||
};
|
||||
});
|
||||
expect(cycleState.cursorCount).toBe(1);
|
||||
expect(cycleState.layerCount).toBe(1);
|
||||
expect(cycleState.animPaused).toBe(true);
|
||||
|
||||
// Close editor
|
||||
await page.evaluate(async () => {
|
||||
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
|
||||
editor.closeEditor({ force: true });
|
||||
});
|
||||
|
||||
// Verify complete cleanup after close
|
||||
await expect(cursor).toHaveCount(0);
|
||||
const postClose = await page.evaluate(() => {
|
||||
const anim = window.__lastCycleAnim;
|
||||
delete window.__lastCycleAnim;
|
||||
return {
|
||||
cursorCount: document.querySelectorAll('.ge-brush-cursor').length,
|
||||
layerCount: document.querySelectorAll('#ge-cursor-layer').length,
|
||||
animIdle: anim ? anim.playState === 'idle' : true,
|
||||
activeAnims: document.getAnimations().filter(a => {
|
||||
const target = a.effect?.target;
|
||||
return target && (target.classList?.contains('ge-brush-cursor') || target.id === 'ge-cursor-layer');
|
||||
}).length,
|
||||
};
|
||||
});
|
||||
expect(postClose.cursorCount).toBe(0);
|
||||
expect(postClose.layerCount).toBe(0);
|
||||
expect(postClose.animIdle).toBe(true);
|
||||
expect(postClose.activeAnims).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user