mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-30 17:52:26 +01:00
ci: gate native suites with isolated browser fixtures
This commit is contained in:
@@ -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 };
|
||||
@@ -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,40 @@ 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 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);
|
||||
const mainThreadDuration = metrics ? (await taskTime()) - beforeTask : null;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user