Squash Odysseus development history

This commit is contained in:
pewdiepie-archdaemon
2026-09-11 06:04:19 +00:00
parent c9dd68d890
commit 84aa9a91de
871 changed files with 265870 additions and 27854 deletions
+18
View File
@@ -0,0 +1,18 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { deliveryMessages, researchCardState } from '../static/js/backgroundToolJobs.js';
test('research cards distinguish live work, handoff, completion and no evidence', () => {
assert.match(researchCardState({ status: 'running', rounds: 2, progress: { phase: 'reading', round: 1, total_sources: 3 } }).detail, /Round 1\/2 · 3 sources/);
assert.equal(researchCardState({ status: 'ready' }).label, 'Preparing chat update');
assert.equal(researchCardState({ status: 'delivered', source_count: 4 }).tone, 'done');
assert.equal(researchCardState({ status: 'delivered', outcome: 'no_sources' }).label, 'No sources found');
assert.equal(researchCardState({ status: 'ready', outcome: 'error' }).tone, 'error');
});
test('only new delivered messages append; history reload and repeat polls deduplicate', () => {
const job = { status: 'delivered', message: { content: 'Found it', metadata: { _db_id: 'result-1' } } };
assert.deepEqual(deliveryMessages([job, job, { status: 'running' }], []), [job.message]);
assert.deepEqual(deliveryMessages([job], ['result-1']), []);
assert.deepEqual(deliveryMessages([{ status: 'ready', message: job.message }], []), []);
});
+15
View File
@@ -0,0 +1,15 @@
# Browser End-to-End Tests
The photo editor release gate launches an isolated, authentication-disabled
Odysseus server on port `7013` with a temporary SQLite database.
```bash
npm install
npm run test:photo-editor:install
npm run test:photo-editor
```
Set `PHOTO_EDITOR_E2E_PORT` to use another port. Failure screenshots and traces
are written to `test-results/photo-editor/`. The server uses the repository
`.venv` automatically; set `ODYSSEUS_TEST_PYTHON` to another Python executable
when using a different environment layout.
@@ -0,0 +1,367 @@
const { test, expect } = require('@playwright/test');
const {
dragOnCanvas,
encodedImagePixelDigest,
editorState,
flattenedPixelDigest,
openBlankEditor,
reopenDraft,
waitForDraft,
} = require('./helpers.js');
async function addAdjustment(page, type) {
await page.locator('#ge-add-layer').click();
const menu = page.locator('.ge-add-layer-menu');
await expect(menu).toBeVisible();
await menu.locator(`[data-adjustment-type="${type}"]`).click();
await expect(page.locator('.ge-adj-popup')).toBeVisible();
}
test('Levels is a retained stack layer with clipping, masks, history, and persistence', async ({ page }) => {
await openBlankEditor(page, { width: 360, height: 260 }, 'Adjustment layers E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.18, y: 0.25 }, { x: 0.78, y: 0.7 });
const before = await flattenedPixelDigest(page);
await addAdjustment(page, 'levels');
await page.locator('.ge-adj-channel-select').selectOption('red');
await page.locator('.ge-adj-row input[data-key="outWhite"]').fill('128');
await page.locator('.ge-adj-row input[data-key="outWhite"]').dispatchEvent('input');
await page.locator('[data-adj-action="ok"]').click();
let current = await editorState(page);
const levels = current.layers.find(layer => layer.kind === 'adjustment');
expect(levels.adjustment.type).toBe('levels');
expect(levels.adjustment.params.channels.red.outWhite).toBe(128);
expect(await flattenedPixelDigest(page)).not.toEqual(before);
const row = page.locator(`.ge-layer-item[data-layer-id="${levels.id}"]`);
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).clipped).toBe(true);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === levels.id).masks).toHaveLength(1);
await row.locator('.ge-layer-vis').click();
expect(await flattenedPixelDigest(page)).toEqual(before);
await row.locator('.ge-layer-vis').click();
await row.locator('.ge-layer-opacity').fill('45');
await row.locator('.ge-layer-opacity').dispatchEvent('input');
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).opacity).toBeCloseTo(.45, 2);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).opacity).toBe(1);
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers.find(layer => layer.id === levels.id).opacity).toBeCloseTo(.45, 2);
const draftId = await waitForDraft(page);
const expected = await editorState(page);
const expectedPixels = await flattenedPixelDigest(page);
const exportedPng = await page.evaluate(async () => {
const editor = await import('/static/js/galleryEditor.js');
return editor.exportPNG();
});
expect(await encodedImagePixelDigest(
page,
Buffer.from(exportedPng.split(',', 2)[1], 'base64'),
)).toEqual(expectedPixels);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.layers.find(layer => layer.id === levels.id)).toEqual(expected.layers.find(layer => layer.id === levels.id));
expect(await flattenedPixelDigest(page)).toEqual(expectedPixels);
});
test('Curves supports editable RGB and channel points with reset and cancel', async ({ page }) => {
await openBlankEditor(page, { width: 360, height: 260 }, 'Curves E2E');
await addAdjustment(page, 'curves');
const curve = page.locator('.ge-curves-canvas');
const box = await curve.boundingBox();
await page.mouse.click(box.x + box.width * .5, box.y + box.height * .28);
await page.locator('.ge-adj-channel-select').selectOption('blue');
const blueBox = await curve.boundingBox();
await page.mouse.click(blueBox.x + blueBox.width * .4, blueBox.y + blueBox.height * .68);
await page.locator('[data-adj-action="ok"]').click();
let current = await editorState(page);
const curves = current.layers.find(layer => layer.kind === 'adjustment');
expect(curves.adjustment.type).toBe('curves');
expect(curves.adjustment.params.points.rgb).toHaveLength(3);
expect(curves.adjustment.params.points.blue).toHaveLength(3);
const row = page.locator(`.ge-layer-item[data-layer-id="${curves.id}"]`);
await page.locator('#ge-layer-tools .ge-layer-fx-btn').click();
await page.locator('[data-adj-action="reset"]').click();
await page.locator('[data-adj-action="cancel"]').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === curves.id).adjustment.params.points.rgb).toHaveLength(3);
});
test('color adjustment controls retain neutral reusable parameters', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Color adjustments E2E');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
const gradient = layer.ctx.createLinearGradient(0, 0, layer.canvas.width, layer.canvas.height);
gradient.addColorStop(0, '#b8324f');
gradient.addColorStop(.5, '#56a56d');
gradient.addColorStop(1, '#315fc4');
layer.ctx.fillStyle = gradient;
layer.ctx.fillRect(0, 0, layer.canvas.width, layer.canvas.height);
window.galleryEditorComposite?.();
});
const cases = [
{ type: 'exposure', key: 'exposure', value: '100', check: p => p.exposure === 1 },
{ type: 'white-balance', key: 'temperature', value: '70', check: p => p.temperature === 70 },
{ type: 'hue-saturation', key: 'lightness', value: '18', check: p => p.lightness === 18 },
{ type: 'vibrance', key: 'vibrance', value: '55', check: p => p.vibrance === 55 },
{ type: 'black-white', key: 'red', value: '72', compare: true, check: p => p.red === 72 && p.green === 59 && p.blue === 11 },
{ type: 'shadows-highlights', key: 'shadows', value: '48', check: p => p.shadows === 48 },
{ type: 'color-balance', key: 'shadows-r', value: '45', check: p => p.shadows.r === 45 },
{ type: 'selective-color', key: 'cyan', value: '35', check: p => p.ranges.reds.cyan === 35 },
];
let priorDigest = await flattenedPixelDigest(page);
for (const item of cases) {
await addAdjustment(page, item.type);
const control = page.locator(`.ge-adj-row input[data-key="${item.key}"]`);
await control.fill(item.value);
await control.dispatchEvent('input');
await page.locator('[data-adj-action="ok"]').click();
const current = await editorState(page);
const added = current.layers.filter(layer => layer.kind === 'adjustment').at(-1);
expect(added.adjustment.type).toBe(item.type);
expect(item.check(added.adjustment.params)).toBe(true);
const nextDigest = await flattenedPixelDigest(page);
expect(nextDigest).not.toBe(priorDigest);
if (item.compare) {
await page.locator('#ge-layer-tools .ge-layer-fx-btn').click();
await expect(page.locator('.ge-adj-popup')).toBeVisible();
await page.locator('[data-adj-action="compare"]').click();
expect(await flattenedPixelDigest(page)).toEqual(priorDigest);
await page.locator('[data-adj-action="compare"]').click();
expect(await flattenedPixelDigest(page)).toEqual(nextDigest);
await page.locator('[data-adj-action="cancel"]').click();
}
priorDigest = nextDigest;
}
await addAdjustment(page, 'gradient-map');
await page.locator('[data-gradient-key="shadows"]').fill('#123456');
await page.locator('[data-gradient-key="highlights"]').fill('#f0d080');
await page.locator('[data-gradient-reverse]').check();
await page.locator('[data-adj-action="ok"]').click();
const current = await editorState(page);
const gradient = current.layers.filter(layer => layer.kind === 'adjustment').at(-1);
expect(gradient.adjustment).toEqual({
type: 'gradient-map',
params: { shadows: '#123456', highlights: '#f0d080', midpoint: 50, reverse: true },
});
const expectedPixels = await flattenedPixelDigest(page);
const draftId = await waitForDraft(page);
const expectedState = await editorState(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.filter(layer => layer.kind === 'adjustment')).toEqual(
expectedState.layers.filter(layer => layer.kind === 'adjustment'),
);
expect(await flattenedPixelDigest(page)).toEqual(expectedPixels);
});
test('adjustment presets apply through the retained popup', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Adjustment presets E2E');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.ctx.fillStyle = '#5577aa';
layer.ctx.fillRect(0, 0, layer.canvas.width, layer.canvas.height);
window.galleryEditorComposite?.();
});
const before = await flattenedPixelDigest(page);
await addAdjustment(page, 'exposure');
await page.locator('.ge-adj-preset-select').selectOption('Lift Exposure');
await page.locator('[data-adj-action="ok"]').click();
const current = await editorState(page);
const exposure = current.layers.find(layer => layer.kind === 'adjustment');
expect(exposure.adjustment.params.exposure).toBe(0.45);
expect(await flattenedPixelDigest(page)).not.toEqual(before);
});
test('adjustment popup keeps controls inside a narrow phone viewport', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 700 });
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile adjustment E2E');
await addAdjustment(page, 'exposure');
const popup = await page.locator('.ge-adj-popup').boundingBox();
expect(popup).not.toBeNull();
expect(popup.x).toBeGreaterThanOrEqual(0);
expect(popup.x + popup.width).toBeLessThanOrEqual(320);
for (const row of await page.locator('.ge-adj-row').all()) {
const box = await row.boundingBox();
expect(box).not.toBeNull();
expect(box.x).toBeGreaterThanOrEqual(popup.x);
expect(box.x + box.width).toBeLessThanOrEqual(popup.x + popup.width);
}
await expect(page.locator('.ge-adj-foot [data-adj-action="cancel"]')).toBeVisible();
await expect(page.locator('.ge-adj-foot [data-adj-action="ok"]')).toBeVisible();
});
test('gradient map controls remain usable on a narrow phone viewport', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 700 });
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile gradient map E2E');
await addAdjustment(page, 'gradient-map');
const popup = await page.locator('.ge-adj-popup').boundingBox();
const colors = page.locator('.ge-gradient-color-row');
const colorBox = await colors.boundingBox();
expect(popup).not.toBeNull();
expect(colorBox).not.toBeNull();
expect(colorBox.x).toBeGreaterThanOrEqual(popup.x);
expect(colorBox.x + colorBox.width).toBeLessThanOrEqual(popup.x + popup.width);
expect(await page.locator('.ge-gradient-color-row').evaluate(el => (
getComputedStyle(el).gridTemplateColumns.trim().split(/\s+/).length
))).toBe(1);
});
test('all adjustment popups remain usable on a narrow phone viewport', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 700 });
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile adjustment matrix E2E');
for (const type of [
'brightness-contrast', 'exposure', 'white-balance', 'hue-saturation',
'vibrance', 'black-white', 'shadows-highlights', 'levels', 'curves',
'color-balance', 'selective-color', 'gradient-map',
]) {
await addAdjustment(page, type);
const popup = await page.locator('.ge-adj-popup').boundingBox();
expect(popup, `${type} popup should be visible`).not.toBeNull();
expect(popup.x).toBeGreaterThanOrEqual(0);
expect(popup.x + popup.width).toBeLessThanOrEqual(320);
const body = page.locator('.ge-adj-body');
expect(await body.evaluate(el => el.scrollWidth <= el.clientWidth + 1), `${type} body should not overflow horizontally`).toBe(true);
await expect(page.locator('.ge-adj-foot [data-adj-action="cancel"]')).toBeVisible();
await expect(page.locator('.ge-adj-foot [data-adj-action="ok"]')).toBeVisible();
await page.locator('[data-adj-action="cancel"]').click();
}
});
test('retained Gaussian Blur survives flattening and draft reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained effects E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.2, y: 0.25 }, { x: 0.8, y: 0.7 });
const before = await flattenedPixelDigest(page);
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
let current = await editorState(page);
const layer = current.layers.find(item => item.effects?.length);
expect(layer.effects).toHaveLength(1);
expect(layer.effects[0].type).toBe('gaussian-blur');
expect(layer.effects[0].params.radius).toBe(14);
const after = await flattenedPixelDigest(page);
expect(after).not.toEqual(before);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
current = await editorState(page);
const reopened = current.layers.find(item => item.effects?.length);
expect(reopened.effects).toEqual(layer.effects);
expect(await flattenedPixelDigest(page)).toEqual(after);
await page.locator('.ge-effect-sub-item .ge-adj-sub-name').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('22');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].params.radius).toBe(22);
});
test('retained Sharpen keeps editable amount and survives draft reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained sharpen E2E');
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-preset-crisp-detail"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
expect(await page.locator('.ge-filter-row input[data-key="amount"]').inputValue()).toBe('35');
await page.locator('.ge-filter-row input[data-key="amount"]').fill('75');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
let current = await editorState(page);
let layer = current.layers.find(item => item.effects?.length);
expect(layer.effects[0].type).toBe('sharpen');
expect(layer.effects[0].params.amount).toBeCloseTo(0.75, 2);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
current = await editorState(page);
layer = current.layers.find(item => item.effects?.length);
expect(layer.effects[0].type).toBe('sharpen');
expect(layer.effects[0].params.amount).toBeCloseTo(0.75, 2);
});
test('retained overlay and shadow effects keep editable metadata', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained color effects E2E');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.move(canvasBox.x + 24, canvasBox.y + 24);
await page.mouse.down();
await page.mouse.move(canvasBox.x + 150, canvasBox.y + 140);
await page.mouse.up();
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-color-overlay"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
await page.locator('button[title="Add effect mask from selection"]').click();
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-drop-shadow"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="blur"]').fill('18');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
let current = await editorState(page);
let layer = current.layers.find(item => item.effects?.length);
expect(layer.effects.map(effect => effect.type)).toEqual(['color-overlay', 'drop-shadow']);
expect(layer.effects[0].params.color).toBe('#336699');
expect(layer.effects[0].mask.size).toEqual([320, 220]);
await page.locator('button[title="Hide effect mask"]').click();
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].mask.visible).toBe(false);
await page.locator('button[title="Remove effect mask"]').click();
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].mask).toBeNull();
expect(layer.effects[0].params.opacity).toBeCloseTo(0.35, 2);
expect(layer.effects[1].params.blur).toBe(18);
await page.locator('.ge-effect-sub-item').nth(0).locator('.ge-layer-vis').click();
expect((await editorState(page)).layers.find(item => item.effects?.length).effects[0].visible).toBe(false);
await page.locator('.ge-effect-sub-item').nth(1).locator('button[title="Move effect up"]').click();
current = await editorState(page);
layer = current.layers.find(item => item.effects?.length);
expect(layer.effects.map(effect => effect.type)).toEqual(['drop-shadow', 'color-overlay']);
await page.locator('.ge-effect-sub-item').nth(1).locator('button[title="Delete effect"]').click();
expect((await editorState(page)).layers.find(item => item.effects?.length).effects).toHaveLength(1);
});
test('retained Stroke uses layer alpha and persists its controls', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained stroke E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.75, y: 0.7 });
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-stroke"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="width"]').fill('9');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
const layer = (await editorState(page)).layers.find(item => item.effects?.length);
expect(layer.effects[0].type).toBe('stroke');
expect(layer.effects[0].params.width).toBe(9);
const beforeRasterize = await flattenedPixelDigest(page);
await page.locator('.ge-effect-sub-item button[title="Rasterize effects"]').click();
await expect.poll(async () => (await editorState(page)).layers.find(item => item.name === 'Edit').effects.length).toBe(0);
const rasterized = (await editorState(page)).layers.find(item => item.name === 'Edit');
expect(rasterized.effects).toHaveLength(0);
expect(await flattenedPixelDigest(page)).toEqual(beforeRasterize);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
expect((await editorState(page)).layers.find(item => item.name === 'Edit').effects).toHaveLength(0);
});
@@ -0,0 +1,100 @@
const { test, expect } = require('@playwright/test');
test('mobile compare uses tabs to show one mounted pane at a time', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/login');
await page.addStyleTag({ url: '/static/style.css?v=20260903comparemodeicons1-emailsettingscards1' });
await page.evaluate(async () => {
const { default: state } = await import('/static/js/compare/state.js');
const { mountMobilePaneTabs } = await import('/static/js/compare/panes.js?v=20260903comparemodeicons1');
document.body.innerHTML = '<main id="compare-test-host" class="chat-container compare-active"><div class="compare-grid" data-cols="3"></div></main>';
const host = document.getElementById('compare-test-host');
host.style.cssText = 'position:fixed;inset:0;display:flex;flex-direction:column;padding-top:12px;';
const grid = host.querySelector('.compare-grid');
state._blindMode = false;
state._parallel = true;
state._selectedModels = [
{ name: 'Alpha model' },
{ name: 'Beta model' },
{ name: 'Gamma model' },
];
state._activeMobilePane = 0;
state._selectedModels.forEach((model, index) => {
const pane = document.createElement('section');
pane.className = 'compare-pane';
pane.dataset.pane = String(index);
pane.innerHTML = `<header class="pane-header"><button id="cmp-title-${index}" class="pane-title-btn">${model.name}</button></header><div class="chat-history">Response ${index + 1}</div>`;
grid.appendChild(pane);
});
mountMobilePaneTabs(host, grid);
});
const tabs = page.locator('.compare-mobile-tab');
const panes = page.locator('.compare-pane');
await expect(tabs).toHaveCount(3);
await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'true');
await expect(panes.nth(0)).toBeVisible();
await expect(panes.nth(1)).toBeHidden();
await expect(panes.nth(2)).toBeHidden();
await tabs.nth(1).click();
await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true');
await expect(panes.nth(0)).toBeHidden();
await expect(panes.nth(1)).toBeVisible();
await expect(panes.nth(2)).toBeHidden();
const geometry = await page.evaluate(() => ({
grid: document.querySelector('.compare-grid').getBoundingClientRect().toJSON(),
pane: document.querySelector('.compare-pane-mobile-active').getBoundingClientRect().toJSON(),
visiblePaneCount: Array.from(document.querySelectorAll('.compare-pane')).filter((pane) => getComputedStyle(pane).display !== 'none').length,
}));
expect(geometry.visiblePaneCount).toBe(1);
expect(Math.abs((geometry.grid.width - 16) - geometry.pane.width)).toBeLessThan(2);
});
test('mobile compare probe keeps feedback below models and actions split', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/login');
await page.addStyleTag({ url: '/static/style.css?v=20260903comparemodeicons1-emailsettingscards1' });
await page.evaluate(() => {
document.body.innerHTML = `
<div class="compare-probe-overlay">
<section class="compare-probe-card">
<div class="compare-probe-title">Checking models...</div>
<div class="compare-probe-list">
<div class="compare-probe-row"><span class="compare-probe-spinner">▁▂▃</span><span class="compare-probe-name">Alpha</span></div>
<div class="compare-probe-row fail"><span class="compare-probe-spinner fail">×</span><span class="compare-probe-name">Beta</span></div>
</div>
<div class="compare-probe-feedback">
<div class="compare-probe-detail"><span class="compare-probe-detail-message">Insufficient balance</span><button class="compare-probe-action-btn"><svg></svg><span>Retry</span></button><button class="compare-probe-action-btn"><svg></svg><span>Swap</span></button></div>
</div>
<div class="compare-probe-footer"><button class="cmp-btn-secondary compare-probe-footer-btn">Go Back</button><button class="cmp-btn-primary compare-probe-footer-btn compare-probe-start-anyway">Start Anyway</button></div>
</section>
</div>`;
});
const layout = await page.evaluate(() => {
const list = document.querySelector('.compare-probe-list').getBoundingClientRect();
const feedback = document.querySelector('.compare-probe-feedback').getBoundingClientRect();
const back = document.querySelector('.compare-probe-footer-btn').getBoundingClientRect();
const start = document.querySelector('.compare-probe-start-anyway').getBoundingClientRect();
const spinnerStyle = getComputedStyle(document.querySelector('.compare-probe-spinner'));
const cardStyle = getComputedStyle(document.querySelector('.compare-probe-card'));
const startStyle = getComputedStyle(document.querySelector('.compare-probe-start-anyway'));
return {
feedbackBelowList: feedback.top >= list.bottom,
splitActions: back.left < start.left && start.right > 350,
spinnerTransform: spinnerStyle.transform,
cardRadius: cardStyle.borderRadius,
startBackground: startStyle.backgroundColor,
};
});
expect(layout.feedbackBelowList).toBe(true);
expect(layout.splitActions).toBe(true);
expect(layout.spinnerTransform).toContain('-2');
expect(layout.cardRadius).toBe('8px');
expect(layout.startBackground).not.toBe('rgba(0, 0, 0, 0)');
});
@@ -0,0 +1,47 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor } = require('./helpers.js');
test('brush cursor matches the rendered brush diameter and replaces the crosshair', async ({ page }) => {
await openBlankEditor(page, { width: 400, height: 300 });
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 80;
});
const canvas = page.locator('.ge-main-canvas');
const canvasBox = await canvas.boundingBox();
await page.mouse.move(canvasBox.x + canvasBox.width / 2, canvasBox.y + canvasBox.height / 2);
const cursor = page.locator('.ge-brush-cursor');
await expect(cursor).toBeVisible();
await expect(canvas).toHaveCSS('cursor', 'none');
const measurements = await page.evaluate(() => {
const canvasEl = document.querySelector('.ge-main-canvas');
const cursorEl = document.querySelector('.ge-brush-cursor');
const canvasRect = canvasEl.getBoundingClientRect();
const cursorRect = cursorEl.getBoundingClientRect();
return {
expectedWidth: 80 * canvasRect.width / canvasEl.width,
expectedHeight: 80 * canvasRect.height / canvasEl.height,
cursorWidth: cursorRect.width,
cursorHeight: cursorRect.height,
cursorZ: Number(getComputedStyle(cursorEl).zIndex),
galleryZ: Number(getComputedStyle(document.getElementById('gallery-modal')).zIndex || 0),
};
});
expect(measurements.cursorWidth).toBeCloseTo(measurements.expectedWidth, 1);
expect(measurements.cursorHeight).toBeCloseTo(measurements.expectedHeight, 1);
expect(measurements.cursorZ).toBeGreaterThan(measurements.galleryZ);
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 160;
});
await page.mouse.move(canvasBox.x + canvasBox.width / 2 + 1, canvasBox.y + canvasBox.height / 2);
await expect.poll(async () => (await cursor.boundingBox()).width)
.toBeCloseTo(measurements.expectedWidth * 2, 1);
});
+197
View File
@@ -0,0 +1,197 @@
const { test, expect } = require('@playwright/test');
const { editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
async function canvasPoint(page, xRatio, yRatio) {
const box = await page.locator('.ge-main-canvas').boundingBox();
return { x: box.x + box.width * xRatio, y: box.y + box.height * yRatio };
}
test('brush presets and shared stroke controls remain reusable', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await expect(page.locator('#ge-brush-spacing')).toBeVisible();
await page.locator('#ge-brush-spacing').fill('28');
await page.locator('#ge-brush-smoothing').fill('52');
await page.locator('#ge-brush-blend').selectOption('multiply');
await page.locator('.ge-pressure-option').filter({ hasText: 'Opacity' }).locator('.toggle-slider').click();
await expect(page.locator('#ge-pressure-opacity')).toBeChecked();
page.once('dialog', dialog => dialog.accept('My Detail Brush'));
await page.locator('#ge-brush-preset-save').click();
await expect(page.locator('#ge-brush-preset option', { hasText: 'My Detail Brush' })).toHaveCount(1);
await page.locator('#ge-brush-spacing').fill('5');
await page.locator('#ge-brush-preset').selectOption({ label: 'My Detail Brush' });
await expect(page.locator('#ge-brush-spacing')).toHaveValue('28');
await expect(page.locator('#ge-brush-smoothing')).toHaveValue('52');
await expect(page.locator('#ge-brush-blend')).toHaveValue('multiply');
await page.locator('#ge-brush-preset-delete').click();
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 }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.locator('.ge-size-slider').fill('700');
await page.locator('#ge-brush-spacing').fill('8');
await page.locator('#ge-brush-smoothing').fill('65');
const activeLayerId = (await editorState(page)).activeLayerId;
const thumb = page.locator(`.ge-layer-item[data-layer-id="${activeLayerId}"] .ge-layer-inline-thumb`);
const thumbBefore = await thumb.evaluate(canvas => canvas.toDataURL());
const before = await flattenedPixelDigest(page);
const start = await canvasPoint(page, 0.08, 0.5);
const end = await canvasPoint(page, 0.92, 0.5);
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();
expect(Date.now() - startedAt).toBeLessThan(5000);
const after = await flattenedPixelDigest(page);
expect(after).not.toEqual(before);
await expect(thumb).toHaveAttribute('width', '68');
expect(await thumb.evaluate(canvas => canvas.toDataURL())).not.toEqual(thumbBefore);
await page.locator('#ge-undo').click();
expect(await flattenedPixelDigest(page)).toEqual(before);
await page.locator('#ge-redo').click();
expect(await flattenedPixelDigest(page)).toEqual(after);
});
test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#33aa55';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
const sample = await canvasPoint(page, 0.3, 0.3);
await page.mouse.click(sample.x, sample.y);
await page.locator('.ge-tool-btn[data-tool="eyedropper"]').click();
await expect(page.locator('#ge-eyedropper-section')).toBeVisible();
await page.locator('#ge-eyedropper-sample').selectOption('composite');
await page.mouse.move(sample.x, sample.y);
await expect(page.locator('#ge-eyedropper-live-value')).toHaveText('#33aa55');
await expect(page.locator('#ge-eyedropper-live-rgb')).toHaveText('RGB 51 170 85');
await expect(page.locator('#ge-eyedropper-live-hsl')).toHaveText('HSL 137 54% 43%');
await expect(page.locator('#ge-eyedropper-live-swatch')).not.toHaveClass(/empty/);
await expect(page.locator('#ge-eyedropper-loupe')).toBeVisible();
expect((await page.locator('#ge-eyedropper-loupe').evaluate(canvas => canvas.getContext('2d').getImageData(42, 42, 1, 1).data[3]))).toBe(255);
await page.mouse.click(sample.x, sample.y);
await expect(page.locator('.ge-color-picker').first()).toHaveValue('#33aa55');
for (const tool of ['dodge', 'burn']) {
const before = await flattenedPixelDigest(page);
await page.locator(`.ge-tool-btn[data-tool="${tool}"]`).click();
const from = await canvasPoint(page, 0.35, tool === 'dodge' ? 0.45 : 0.6);
const to = await canvasPoint(page, 0.65, tool === 'dodge' ? 0.45 : 0.6);
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(to.x, to.y, { steps: 20 });
await page.mouse.up();
expect((await editorState(page)).layers.length).toBeGreaterThan(0);
await page.locator('#ge-undo').click();
expect(await flattenedPixelDigest(page)).toEqual(before);
}
const blemish = await canvasPoint(page, 0.72, 0.3);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#111111';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.mouse.click(blemish.x, blemish.y);
await page.locator('.ge-tool-btn[data-tool="heal"]').click();
const beforeHeal = await flattenedPixelDigest(page);
const healTarget = blemish;
await page.mouse.move(healTarget.x, healTarget.y);
await page.mouse.down();
await page.mouse.move(healTarget.x + 50, healTarget.y, { steps: 12 });
await page.mouse.up();
expect(await flattenedPixelDigest(page)).not.toEqual(beforeHeal);
await page.locator('#ge-undo').click();
expect(await flattenedPixelDigest(page)).toEqual(beforeHeal);
});
test('healing brush can use an optional sampled source', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Sampled healing E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.locator('.ge-size-slider').fill('420');
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#d24b62';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
const source = await canvasPoint(page, 0.28, 0.5);
await page.mouse.click(source.x, source.y);
await page.locator('.ge-tool-btn[data-tool="heal"]').click();
await page.keyboard.down('Alt');
await page.mouse.click(source.x, source.y);
await page.keyboard.up('Alt');
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.cloneSourceX !== null && state.cloneSourceY !== null;
})).toBe(true);
const target = await canvasPoint(page, 0.72, 0.5);
await page.mouse.click(target.x, target.y);
const result = await page.evaluate(async ({ xRatio, yRatio }) => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
const x = Math.round(layer.canvas.width * xRatio);
const y = Math.round(layer.canvas.height * yRatio);
const point = layer.ctx.getImageData(x, y, 1, 1).data;
return { hasSource: !!state.cloneSourceSnapshot, pixel: [...point] };
}, { xRatio: 0.72, yRatio: 0.5 });
expect(result.hasSource).toBe(true);
expect(result.pixel[0]).toBeGreaterThan(80);
expect(result.pixel[3]).toBeGreaterThan(0);
await expect(page.locator('#ge-clone-source-label')).toHaveText('Active layer sampled');
await page.locator('#ge-clone-source-clear').click();
await expect(page.locator('#ge-clone-source-label')).toHaveText('No source selected');
});
test('smudge carries nearby pixels along a stroke and undoes atomically', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Smudge E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.locator('.ge-size-slider').fill('700');
await page.locator('.ge-color-picker').first().evaluate((input) => {
input.value = '#d24b62';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
const source = await canvasPoint(page, 0.28, 0.5);
const target = await canvasPoint(page, 0.55, 0.5);
await page.mouse.click(source.x, source.y);
const before = await page.evaluate(() => {
const canvas = document.querySelector('.ge-main-canvas');
const x = Math.round(canvas.width * 0.55);
const y = Math.round(canvas.height * 0.5);
return [...canvas.getContext('2d').getImageData(x, y, 1, 1).data];
});
await page.locator('.ge-tool-btn[data-tool="smudge"]').click();
await expect(page.locator('#ge-smudge-strength-row')).toBeVisible();
await page.mouse.move(source.x, source.y);
await page.mouse.down();
await page.mouse.move(target.x, target.y, { steps: 20 });
await page.mouse.up();
const after = await page.evaluate(() => {
const canvas = document.querySelector('.ge-main-canvas');
const x = Math.round(canvas.width * 0.55);
const y = Math.round(canvas.height * 0.5);
const ctx = canvas.getContext('2d');
return [...ctx.getImageData(x, y, 1, 1).data];
});
expect(before[0]).toBeGreaterThan(220);
expect(before[3]).toBe(255);
expect(after[0]).toBeLessThan(220);
expect(after[2]).toBeLessThan(220);
expect(after[3]).toBeGreaterThan(0);
await page.locator('#ge-undo').click();
expect(await page.evaluate(() => {
const canvas = document.querySelector('.ge-main-canvas');
const x = Math.round(canvas.width * 0.55);
const y = Math.round(canvas.height * 0.5);
return canvas.getContext('2d').getImageData(x, y, 1, 1).data[3];
})).toBe(255);
});
@@ -0,0 +1,43 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor } = require('./helpers.js');
async function mainCanvasHash(page) {
return page.evaluate(() => {
const canvas = document.querySelector('.ge-main-canvas');
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
let hash = 2166136261;
for (let index = 0; index < data.length; index += 4) {
hash ^= data[index];
hash = Math.imul(hash, 16777619);
hash ^= data[index + 1];
hash = Math.imul(hash, 16777619);
hash ^= data[index + 2];
hash = Math.imul(hash, 16777619);
hash ^= data[index + 3];
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
});
}
test('before compare is visual-only and toggles back to the edited document', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 180 }, 'Compare mode E2E');
await expect(page.locator('#ge-compare-btn')).toBeVisible();
await expect.poll(async () => (await editorState(page)).documentRenderReady).toBe(true);
const baselineHash = await mainCanvasHash(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.7 });
const editedState = await editorState(page);
const editedHash = await mainCanvasHash(page);
expect(editedHash).not.toBe(baselineHash);
await page.locator('#ge-compare-btn').click();
await expect(page.locator('#ge-compare-btn')).toHaveAttribute('aria-pressed', 'true');
expect(await mainCanvasHash(page)).toBe(baselineHash);
expect((await editorState(page)).layers).toEqual(editedState.layers);
await page.locator('#ge-compare-btn').click();
await expect(page.locator('#ge-compare-btn')).toHaveAttribute('aria-pressed', 'false');
expect(await mainCanvasHash(page)).toBe(editedHash);
});
@@ -0,0 +1,106 @@
const fs = require('node:fs');
const { test, expect } = require('@playwright/test');
const {
compareExportPixels,
dragOnCanvas,
encodedImagePixelDigest,
editorState,
flattenedPixelDigest,
openBlankEditor,
openExportDialog,
reopenDraft,
waitForDraft,
} = require('./helpers.js');
test('layered document survives edit, mask, transform, crop, reopen, and export', async ({ page, request }) => {
await openBlankEditor(page);
const initial = await editorState(page);
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.20, y: 0.30 }, { x: 0.72, y: 0.60 });
const painted = await editorState(page);
const paintedLayer = painted.layers.find(layer => layer.name === 'Edit');
const initialLayer = initial.layers.find(layer => layer.name === 'Edit');
expect(paintedLayer.pixelHash).not.toBe(initialLayer.pixelHash);
const editItem = page.locator('.ge-layer-item').filter({ hasText: 'Edit' }).first();
await editItem.click();
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
let current = await editorState(page);
expect(current.layers.find(layer => layer.name === 'Edit').masks).toHaveLength(1);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await expect(page.locator('#ge-transform-w')).toBeVisible();
await page.locator('#ge-transform-w').fill('560');
await page.locator('#ge-transform-apply').click();
current = await editorState(page);
const transformed = current.layers.find(layer => layer.name === 'Edit');
expect(transformed.size[0]).toBe(560);
expect(transformed.masks[0].size).toEqual(transformed.size);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').size[0]).toBe(640);
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').size[0]).toBe(560);
await page.locator('.ge-tool-btn[data-tool="text"]').click();
await expect(page.locator('#ge-text-section')).toBeVisible();
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(canvasBox.x + canvasBox.width * 0.28, canvasBox.y + canvasBox.height * 0.22);
await page.locator('#ge-text-content').fill('Durable photo edit');
await page.locator('#ge-text-size').fill('42');
await page.locator('#ge-text-size').press('Enter');
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Durable photo edit' })).toBeVisible();
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await dragOnCanvas(page, { x: 0.08, y: 0.10 }, { x: 0.92, y: 0.88 });
await expect(page.locator('.ge-crop-apply-btn')).toBeVisible();
await page.locator('.ge-crop-apply-btn').click();
const cropped = await editorState(page);
expect(cropped.dimensions[0]).toBeLessThan(640);
expect(cropped.dimensions[1]).toBeLessThan(480);
await page.locator('#ge-undo').click();
expect((await editorState(page)).dimensions).toEqual([640, 480]);
await page.locator('#ge-redo').click();
expect((await editorState(page)).dimensions).toEqual(cropped.dimensions);
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
const beforeReopenPixels = await flattenedPixelDigest(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.dimensions).toEqual(beforeReopen.dimensions);
expect(reopened.activeLayerId).toBe(beforeReopen.activeLayerId);
expect(reopened.layers).toEqual(beforeReopen.layers);
expect(await flattenedPixelDigest(page)).toEqual(beforeReopenPixels);
await openExportDialog(page);
await page.locator('[data-format="png"]').click();
await page.locator('#ge-export-width').fill('320');
const expectedHeight = Number(await page.locator('#ge-export-height').inputValue());
await page.locator('#ge-export-filename').fill('photo-editor-release-gate');
const downloadPromise = page.waitForEvent('download');
await page.locator('.ge-export-dialog button[type="submit"]').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('photo-editor-release-gate.png');
const bytes = fs.readFileSync(await download.path());
expect(bytes.subarray(1, 4).toString('ascii')).toBe('PNG');
expect(bytes.readUInt32BE(16)).toBe(320);
expect(bytes.readUInt32BE(20)).toBe(expectedHeight);
const resizedComparison = await compareExportPixels(page, bytes, { width: 320, height: expectedHeight });
expect(resizedComparison.meanAbsoluteError).toBeLessThanOrEqual(1);
expect(resizedComparison.maximumError).toBeLessThanOrEqual(32);
expect(resizedComparison.changedPixelRatio).toBeLessThanOrEqual(0.08);
await openExportDialog(page);
await page.locator('[data-format="png"]').click();
await page.locator('#ge-export-filename').fill('photo-editor-native-fidelity');
const nativeDownloadPromise = page.waitForEvent('download');
await page.locator('.ge-export-dialog button[type="submit"]').click();
const nativeDownload = await nativeDownloadPromise;
const nativeBytes = fs.readFileSync(await nativeDownload.path());
expect(await encodedImagePixelDigest(page, nativeBytes)).toEqual(beforeReopenPixels);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,191 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
test('tool switching cancels incomplete crop and selection gestures without stale edits', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Gesture cancellation');
const canvas = page.locator('.ge-main-canvas');
const box = await canvas.boundingBox();
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await page.mouse.move(box.x + 40, box.y + 35);
await page.mouse.down();
await page.mouse.move(box.x + 190, box.y + 150, { steps: 4 });
expect(await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { cropping: state.cropping, rect: state.cropRect };
})).toMatchObject({ cropping: true, rect: { w: 150, h: 115 } });
await page.locator('.ge-tool-btn[data-tool="move"]').dispatchEvent('click');
expect(await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { tool: state.tool, cropping: state.cropping, moving: state.cropMoving, rect: state.cropRect };
})).toEqual({ tool: 'move', cropping: false, moving: false, rect: null });
await page.mouse.up();
await expect(page.locator('.ge-crop-apply')).toHaveCount(0);
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.55, y: 0.55 });
const beforeMove = await editorState(page);
const bounds = beforeMove.selection.bounds;
await page.mouse.move(
box.x + bounds.x + bounds.width / 2,
box.y + bounds.y + bounds.height / 2,
);
await page.mouse.down();
await page.mouse.move(box.x + bounds.x + bounds.width / 2 + 35, box.y + bounds.y + bounds.height / 2 + 20);
expect((await editorState(page)).selection.bounds).not.toEqual(bounds);
await page.locator('.ge-tool-btn[data-tool="brush"]').dispatchEvent('click');
const afterSwitch = await editorState(page);
expect(afterSwitch.selection.bounds).toEqual(bounds);
expect(afterSwitch.undo).toBe(beforeMove.undo);
expect(await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { tool: state.tool, selectionMoving: state.selectionMoving, marqueeActive: state.marqueeActive };
})).toEqual({ tool: 'brush', selectionMoving: false, marqueeActive: false });
await page.mouse.up();
expect((await editorState(page)).selection.bounds).toEqual(bounds);
});
test('Escape cancels an active crop without changing the document', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Escape crop cancellation');
const canvas = page.locator('.ge-main-canvas');
const box = await canvas.boundingBox();
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await page.mouse.move(box.x + 40, box.y + 35);
await page.mouse.down();
await page.mouse.move(box.x + 190, box.y + 150, { steps: 4 });
await page.keyboard.press('Escape');
await page.mouse.up();
await expect(page.locator('.ge-crop-apply')).toHaveCount(0);
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { cropping: state.cropping, moving: state.cropMoving, rect: state.cropRect };
})).toEqual({ cropping: false, moving: false, rect: null });
});
test('touch crop and selection gestures complete through the shared lifecycle', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
serviceWorkers: 'block',
});
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch gestures');
const cdp = await context.newCDPSession(page);
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
type,
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
});
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
await touch('touchStart', canvasBox.x + 35, canvasBox.y + 30);
await touch('touchMove', canvasBox.x + 190, canvasBox.y + 145);
await touch('touchEnd', canvasBox.x + 190, canvasBox.y + 145);
await expect(page.locator('.ge-crop-apply')).toBeVisible();
expect(await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.cropRect;
})).toMatchObject({ w: 155, h: 115 });
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await touch('touchStart', canvasBox.x + 45, canvasBox.y + 40);
await touch('touchMove', canvasBox.x + 165, canvasBox.y + 125);
await touch('touchEnd', canvasBox.x + 165, canvasBox.y + 125);
expect((await editorState(page)).selection.bounds).toBeTruthy();
} finally {
await context.close();
}
});
test('touch brush paints and undoes as one stroke', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
serviceWorkers: 'block',
});
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch brush');
const cdp = await context.newCDPSession(page);
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
type,
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
});
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
const before = await flattenedPixelDigest(page);
await touch('touchStart', canvasBox.x + canvasBox.width * 0.25, canvasBox.y + canvasBox.height * 0.5);
await touch('touchMove', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
await touch('touchEnd', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
const after = await flattenedPixelDigest(page);
expect(after).not.toEqual(before);
await page.locator('#ge-undo').click();
expect(await flattenedPixelDigest(page)).toEqual(before);
} finally {
await context.close();
}
});
test('touch cancellation rolls back a partial brush stroke', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
serviceWorkers: 'block',
});
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch cancellation');
const cdp = await context.newCDPSession(page);
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
const before = await flattenedPixelDigest(page);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: canvasBox.x + 70, y: canvasBox.y + 110, id: 1 }],
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: canvasBox.x + 230, y: canvasBox.y + 110, id: 1 }],
});
await cdp.send('Input.dispatchTouchEvent', { type: 'touchCancel', touchPoints: [] });
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.drawing;
})).toBe(false);
expect(await flattenedPixelDigest(page)).toEqual(before);
} finally {
await context.close();
}
});
test('pen input completes a crop gesture', async ({ page, context, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP pen injection');
await openBlankEditor(page, { width: 320, height: 240 }, 'Pen crop gesture');
await page.locator('.ge-tool-btn[data-tool="crop"]').click();
const box = await page.locator('.ge-main-canvas').boundingBox();
const cdp = await context.newCDPSession(page);
await cdp.send('Input.dispatchMouseEvent', {
type: 'mousePressed', pointerType: 'pen', button: 'left', buttons: 1,
clickCount: 1, x: box.x + 30, y: box.y + 25,
});
await cdp.send('Input.dispatchMouseEvent', {
type: 'mouseMoved', pointerType: 'pen', button: 'none', buttons: 1,
x: box.x + 175, y: box.y + 130,
});
await cdp.send('Input.dispatchMouseEvent', {
type: 'mouseReleased', pointerType: 'pen', button: 'left', buttons: 0,
clickCount: 1, x: box.x + 175, y: box.y + 130,
});
await expect(page.locator('.ge-crop-apply')).toBeVisible();
expect(await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { cropping: state.cropping, rect: state.cropRect };
})).toEqual({ cropping: false, rect: { x: 30, y: 25, w: 145, h: 105 } });
});
@@ -0,0 +1,191 @@
const fs = require('node:fs');
const { test, expect } = require('@playwright/test');
const {
compareExportPixels,
encodedImagePixelDigest,
editorState,
flattenedPixelDigest,
openBlankEditor,
openExportDialog,
reopenDraft,
waitForDraft,
} = require('./helpers.js');
async function seedGradient(page) {
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
const gradient = layer.ctx.createLinearGradient(0, 0, layer.canvas.width, layer.canvas.height);
gradient.addColorStop(0, '#d43f67');
gradient.addColorStop(0.5, '#4aa884');
gradient.addColorStop(1, '#3868d4');
layer.ctx.fillStyle = gradient;
layer.ctx.fillRect(0, 0, layer.canvas.width, layer.canvas.height);
window.galleryEditorComposite?.();
});
}
async function downloadFormat(page, format, filename) {
await openExportDialog(page);
await page.locator(`[data-format="${format}"]`).click();
await page.locator('#ge-export-filename').fill(filename);
const downloadPromise = page.waitForEvent('download');
await page.locator('.ge-export-dialog button[type="submit"]').click();
const download = await downloadPromise;
return {
name: download.suggestedFilename(),
bytes: fs.readFileSync(await download.path()),
};
}
async function addAdjustment(page, type) {
await page.locator('#ge-add-layer').click();
await expect(page.locator('.ge-add-layer-menu')).toBeVisible();
await page.locator(`.ge-add-layer-menu [data-adjustment-type="${type}"]`).click();
await expect(page.locator('.ge-adj-popup')).toBeVisible();
}
async function exportCurrentPng(page) {
const dataUrl = await page.evaluate(async () => {
const editor = await import('/static/js/galleryEditor.js');
return editor.exportPNG();
});
return Buffer.from(dataUrl.split(',', 2)[1], 'base64');
}
test('every retained adjustment family exports the visible composite exactly', async ({ page }) => {
await openBlankEditor(page, { width: 128, height: 96 }, 'Adjustment export matrix');
await seedGradient(page);
const cases = [
['brightness-contrast', 'brightness', '35'],
['exposure', 'exposure', '35'],
['white-balance', 'temperature', '30'],
['hue-saturation', 'hue', '30'],
['vibrance', 'vibrance', '45'],
['black-white', 'red', '60'],
['shadows-highlights', 'shadows', '35'],
['levels', 'inBlack', '20'],
['color-balance', 'shadows-r', '35'],
['selective-color', 'cyan', '30'],
];
for (const [type, key, value] of cases) {
await addAdjustment(page, type);
const control = page.locator(`.ge-adj-row input[data-key="${key}"]`);
await control.fill(value);
await control.dispatchEvent('input');
await page.locator('[data-adj-action="ok"]').click();
const visibleDigest = await flattenedPixelDigest(page);
expect(await encodedImagePixelDigest(page, await exportCurrentPng(page))).toEqual(visibleDigest);
}
await addAdjustment(page, 'curves');
const curve = page.locator('.ge-curves-canvas');
const curveBox = await curve.boundingBox();
await page.mouse.click(curveBox.x + curveBox.width * 0.5, curveBox.y + curveBox.height * 0.28);
await page.locator('[data-adj-action="ok"]').click();
const curveDigest = await flattenedPixelDigest(page);
expect(await encodedImagePixelDigest(page, await exportCurrentPng(page))).toEqual(curveDigest);
await addAdjustment(page, 'gradient-map');
await page.locator('[data-gradient-key="shadows"]').fill('#102030');
await page.locator('[data-gradient-key="shadows"]').dispatchEvent('input');
await page.locator('[data-adj-action="ok"]').click();
const gradientDigest = await flattenedPixelDigest(page);
expect(await encodedImagePixelDigest(page, await exportCurrentPng(page))).toEqual(gradientDigest);
});
test('composited correction exports preserve pixels and format metadata', async ({ page }) => {
await openBlankEditor(page, { width: 160, height: 120 }, 'Export fidelity');
await seedGradient(page);
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click();
await page.locator('.ge-adj-row input[data-key="exposure"]').fill('35');
await page.locator('.ge-adj-row input[data-key="exposure"]').dispatchEvent('input');
await page.locator('[data-adj-action="ok"]').click();
const renderedPixels = await flattenedPixelDigest(page);
const png = await downloadFormat(page, 'png', 'correction');
expect(png.name).toBe('correction.png');
expect(png.bytes.subarray(1, 4).toString('ascii')).toBe('PNG');
expect(png.bytes.readUInt32BE(16)).toBe(160);
expect(png.bytes.readUInt32BE(20)).toBe(120);
expect(await encodedImagePixelDigest(page, png.bytes)).toEqual(renderedPixels);
const jpeg = await downloadFormat(page, 'jpeg', 'correction-jpeg');
expect(jpeg.name).toBe('correction-jpeg.jpg');
expect(jpeg.bytes[0]).toBe(0xff);
expect(jpeg.bytes[1]).toBe(0xd8);
const webp = await downloadFormat(page, 'webp', 'correction-webp');
expect(webp.name).toBe('correction-webp.webp');
expect(webp.bytes.subarray(0, 4).toString('ascii')).toBe('RIFF');
expect(webp.bytes.subarray(8, 12).toString('ascii')).toBe('WEBP');
const comparison = await compareExportPixels(page, png.bytes, { width: 160, height: 120 });
expect(comparison.meanAbsoluteError).toBe(0);
expect(comparison.maximumError).toBe(0);
const exposure = (await editorState(page)).layers.find(layer => layer.kind === 'adjustment');
const exposureRow = page.locator(`.ge-layer-item[data-layer-id="${exposure.id}"]`);
await exposureRow.locator('.ge-layer-opacity').fill('62');
await exposureRow.locator('.ge-layer-opacity').dispatchEvent('input');
const opacityState = await editorState(page);
expect(opacityState.layers.find(layer => layer.id === exposure.id).opacity).toBeCloseTo(.62, 2);
const opacityPixels = await flattenedPixelDigest(page);
const opacityPng = await downloadFormat(page, 'png', 'correction-opacity');
expect(await encodedImagePixelDigest(page, opacityPng.bytes)).toEqual(opacityPixels);
expect(await compareExportPixels(page, opacityPng.bytes, { width: 160, height: 120 })).toEqual({
meanAbsoluteError: 0,
maximumError: 0,
changedPixelRatio: 0,
});
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(layer => layer.id === exposure.id).opacity).toBeCloseTo(.62, 2);
expect(await flattenedPixelDigest(page)).toEqual(opacityPixels);
});
test('export preview clears stale matte pixels when transparency changes', async ({ page }) => {
await openBlankEditor(page, { width: 160, height: 120 }, 'Export preview');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
for (const layer of state.layers) layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.ctx.fillStyle = '#2671c8';
layer.ctx.fillRect(40, 30, 80, 60);
window.galleryEditorComposite?.();
});
await openExportDialog(page);
const previewPixel = () => page.locator('.ge-export-preview').evaluate(canvas => (
[...canvas.getContext('2d').getImageData(0, 0, 1, 1).data]
));
expect(await previewPixel()).toEqual([0, 0, 0, 0]);
await page.locator('#ge-export-transparency').uncheck();
await page.locator('#ge-export-matte').click();
await page.locator('.cp-hex').fill('#d94747');
await page.locator('.cp-hex').press('Enter');
expect(await previewPixel()).toEqual([217, 71, 71, 255]);
await page.locator('#ge-export-transparency').check();
expect(await previewPixel()).toEqual([0, 0, 0, 0]);
await page.locator('.ge-export-close').click();
});
test('closing export returns focus to the launch control', async ({ page }) => {
await openBlankEditor(page, { width: 160, height: 120 }, 'Export focus');
const launch = page.locator('#ge-save-menu-btn');
await launch.focus();
await launch.click();
await page.locator('#ge-download').click();
await expect(page.locator('.ge-export-dialog')).toBeVisible();
await page.locator('.ge-export-close').click();
await expect.poll(() => page.evaluate(() => document.activeElement?.id)).toBe('ge-save-menu-btn');
});
@@ -0,0 +1,47 @@
const { test, expect } = require('@playwright/test');
test('gallery photos body uses tall viewport without cropping or stretching cards', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1200 });
await page.route('**/api/gallery/library?**', async route => {
const pixel = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="160" height="160"%3E%3Crect width="160" height="160" fill="%23666"/%3E%3C/svg%3E';
const items = Array.from({ length: 60 }, (_, index) => ({
id: `layout-${index}`,
url: pixel,
thumbnail_url: pixel,
filename: `Layout ${index}.png`,
prompt: `Layout ${index}`,
model: 'imported',
created_at: '2026-08-31T00:00:00Z',
}));
await route.fulfill({ json: { items, total: items.length, has_more: false } });
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
await page.locator('#tool-gallery-btn').click();
await expect(page.locator('#gallery-modal')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('#gallery-grid .gallery-card')).toHaveCount(61);
await expect(page.locator('#gallery-grid')).not.toHaveClass(/gallery-just-opened/, { timeout: 2_000 });
const layout = await page.evaluate(() => {
const modal = document.querySelector('.gallery-modal-content').getBoundingClientRect();
const body = document.querySelector('#gallery-modal .modal-body').getBoundingClientRect();
const grid = document.querySelector('#gallery-grid').getBoundingClientRect();
const upload = document.querySelector('#gallery-upload-tile').getBoundingClientRect();
const cards = [...document.querySelectorAll('#gallery-grid .gallery-card')]
.slice(0, 12)
.map(card => card.getBoundingClientRect());
return { modal, body, grid, upload, cards, viewportHeight: innerHeight };
});
expect(layout.grid.height).toBeGreaterThan(layout.viewportHeight * 0.65);
expect(layout.grid.bottom).toBeLessThanOrEqual(layout.body.bottom + 1);
expect(Math.abs(layout.upload.width - layout.upload.height)).toBeLessThanOrEqual(2);
for (let i = 0; i < layout.cards.length; i += 1) {
for (let j = i + 1; j < layout.cards.length; j += 1) {
const a = layout.cards[i];
const b = layout.cards[j];
const overlaps = a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
expect(overlaps).toBe(false);
}
}
});
@@ -0,0 +1,108 @@
const { test, expect } = require('@playwright/test');
const fs = require('node:fs');
const {
compareExportPixels,
dragOnCanvas,
editorState,
flattenedPixelDigest,
openBlankEditor,
openExportDialog,
reopenDraft,
waitForDraft,
} = require('./helpers.js');
async function downloadPng(page, filename) {
await openExportDialog(page);
await page.locator('[data-format="png"]').click();
await page.locator('#ge-export-filename').fill(filename);
const downloadPromise = page.waitForEvent('download');
await page.locator('.ge-export-dialog button[type="submit"]').click();
const download = await downloadPromise;
return fs.readFileSync(await download.path());
}
test('group retained effects edit, toggle, reorder surface, and survive reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 360, height: 240 }, 'Group effects E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 48;
state.brushSoftness = 0;
});
await dragOnCanvas(page, { x: 0.25, y: 0.35 }, { x: 0.75, y: 0.65 });
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
await backgroundRow.click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
const groupId = (await editorState(page)).groups[0].id;
const groupRow = page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`);
await page.locator('#ge-layer-tools .ge-layer-btn[title*="effect"]').click();
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
let current = await editorState(page);
expect(current.groups[0].effects).toHaveLength(1);
expect(current.groups[0].effects[0].params.radius).toBe(14);
const afterAdd = await flattenedPixelDigest(page);
await expect(page.locator('.ge-group-effect-sub-item')).toHaveCount(1);
await page.locator('.ge-group-effect-sub-item .ge-adj-sub-name').click();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('22');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
current = await editorState(page);
expect(current.groups[0].effects[0].params.radius).toBe(22);
expect(await flattenedPixelDigest(page)).not.toEqual(afterAdd);
await page.locator('.ge-group-effect-sub-item .ge-layer-vis').click();
expect((await editorState(page)).groups[0].effects[0].visible).toBe(false);
await page.locator('.ge-group-effect-sub-item .ge-layer-vis').click();
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
await reopenDraft(page, draftId);
expect((await editorState(page)).groups).toEqual(beforeReopen.groups);
await expect(page.locator('.ge-group-effect-sub-item')).toHaveCount(1);
await page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`).click();
await expect(page.locator('.ge-group-effect-sub-item')).toHaveCount(1);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('group retained effects export the visible composite and survive reopen', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 180 }, 'Group effects export E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 64;
state.brushSoftness = 0;
});
await dragOnCanvas(page, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.7 });
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
await page.locator('#ge-layer-tools .ge-layer-btn[title*="effect"]').click();
await page.locator('[data-filter-action="effect-color-overlay"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('40');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
const renderedPixels = await flattenedPixelDigest(page);
const pngBytes = await downloadPng(page, 'group-effects');
const comparison = await compareExportPixels(page, pngBytes, { width: 240, height: 180 });
expect(comparison.meanAbsoluteError).toBe(0);
expect(comparison.maximumError).toBe(0);
expect(await flattenedPixelDigest(page)).toEqual(renderedPixels);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.groups[0].effects).toHaveLength(1);
expect(reopened.groups[0].effects[0]).toMatchObject({
type: 'color-overlay',
params: { color: '#336699', opacity: 0.4 },
});
});
+68
View File
@@ -0,0 +1,68 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
async function compositeAlphaAtCenter(page) {
return page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const x = Math.floor(state.imgWidth / 2);
const y = Math.floor(state.imgHeight / 2);
return state.documentCompositeCanvas.getContext('2d').getImageData(x, y, 1, 1).data[3];
});
}
test('group mask paints, toggles, survives history and reopen, and protects ungroup', async ({ page, request }) => {
await openBlankEditor(page, { width: 420, height: 300 }, 'Group mask E2E');
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
const groupId = (await editorState(page)).groups[0].id;
const groupRow = page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`);
await page.locator('#ge-layer-tools .ge-group-mask-btn').click();
await expect(page.locator('.ge-group-mask-sub-item')).toHaveCount(1);
await expect(page.locator('#ge-layer-tools button[title="Delete group masks before ungrouping"]')).toBeDisabled();
let current = await editorState(page);
expect(current.groups[0].masks).toHaveLength(1);
expect(current.groups[0].masks[0]).toMatchObject({ mode: 'group', size: [420, 300] });
const whiteMaskHash = current.groups[0].masks[0].pixelHash;
expect(await compositeAlphaAtCenter(page)).toBe(255);
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.brushSize = 90;
state.eraserSoftness = 0;
});
await dragOnCanvas(page, { x: 0.46, y: 0.5 }, { x: 0.54, y: 0.5 });
current = await editorState(page);
const erasedMaskHash = current.groups[0].masks[0].pixelHash;
expect(erasedMaskHash).not.toBe(whiteMaskHash);
expect(await compositeAlphaAtCenter(page)).toBe(0);
await page.locator('#ge-undo').click();
expect((await editorState(page)).groups[0].masks[0].pixelHash).toBe(whiteMaskHash);
expect(await compositeAlphaAtCenter(page)).toBe(255);
await page.locator('#ge-redo').click();
expect((await editorState(page)).groups[0].masks[0].pixelHash).toBe(erasedMaskHash);
expect(await compositeAlphaAtCenter(page)).toBe(0);
const maskRow = page.locator('.ge-group-mask-sub-item');
await maskRow.locator('.ge-layer-vis').click();
expect(await compositeAlphaAtCenter(page)).toBe(255);
await maskRow.locator('.ge-layer-vis').click();
expect(await compositeAlphaAtCenter(page)).toBe(0);
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.groups).toEqual(beforeReopen.groups);
expect(await compositeAlphaAtCenter(page)).toBe(0);
await expect(page.locator('.ge-group-mask-sub-item')).toHaveCount(1);
await page.locator('.ge-group-mask-sub-item button[title="Delete group mask"]').click();
await expect(page.locator('.ge-group-mask-sub-item')).toHaveCount(0);
await groupRow.click();
await page.locator('#ge-layer-tools button[title="Ungroup layers"]').click();
expect((await editorState(page)).groups).toHaveLength(0);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,107 @@
const { test, expect } = require('@playwright/test');
const { editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
test('group drag reorders the complete subtree with undo and server persistence', async ({ page, request }) => {
await openBlankEditor(page, { width: 420, height: 300 }, 'Group reorder E2E');
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
let current = await editorState(page);
const group = current.groups[0];
const originalOrder = current.layers.map(layer => layer.id);
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
current = await editorState(page);
const looseId = current.activeLayerId;
expect(current.layers.map(layer => layer.id)).toEqual([...originalOrder, looseId]);
const handle = page.locator(`.ge-layer-group-row[data-group-id="${group.id}"] .ge-layer-group-drag`);
const looseRow = page.locator(`.ge-layer-item[data-layer-id="${looseId}"]`);
const handleBox = await handle.boundingBox();
const looseBox = await looseRow.boundingBox();
const dragReadiness = await page.evaluate(async ({ groupId, point }) => {
const { state } = await import('/static/js/editor/state.js');
const { groupSiblingUnits } = await import('/static/js/editor/layer-groups.js');
const group = state.layerGroups.find(item => item.id === groupId);
const hit = document.elementFromPoint(point.x, point.y);
return {
siblingIds: groupSiblingUnits(state, group.parentId || null).map(unit => unit.id),
hitHandle: !!hit?.closest('.ge-layer-group-drag'),
};
}, { groupId: group.id, point: { x: handleBox.x + handleBox.width / 2, y: handleBox.y + handleBox.height / 2 } });
expect(dragReadiness.hitHandle, JSON.stringify(dragReadiness)).toBe(true);
expect(dragReadiness.siblingIds).toEqual([group.id, looseId]);
await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2);
await page.mouse.down();
await page.mouse.move(looseBox.x + looseBox.width / 2, looseBox.y + 1, { steps: 8 });
await expect(page.locator('.ge-group-drop-line')).toBeVisible();
await page.mouse.up();
current = await editorState(page);
expect(current.layers.map(layer => layer.id)).toEqual([looseId, ...originalOrder]);
expect(current.groups[0].layerIds).toEqual(group.layerIds);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.map(layer => layer.id)).toEqual([...originalOrder, looseId]);
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers.map(layer => layer.id)).toEqual([looseId, ...originalOrder]);
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.layers.map(layer => layer.id)).toEqual(beforeReopen.layers.map(layer => layer.id));
expect(current.groups).toEqual(beforeReopen.groups);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('mobile layer drag handle supports long-press reorder', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
serviceWorkers: 'block',
});
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile layer reorder');
await page.locator('.ge-layers-header').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
const before = await editorState(page);
// State is bottom-to-top; the panel is top-to-bottom.
const dragId = before.layers[0].id;
const targetId = before.layers[before.layers.length - 1].id;
const bottomHandle = page.locator(`.ge-layer-item[data-layer-id="${dragId}"] .ge-layer-drag`);
const topRow = page.locator(`.ge-layer-item[data-layer-id="${targetId}"]`);
await bottomHandle.scrollIntoViewIfNeeded();
const handleBox = await bottomHandle.boundingBox();
const topBox = await topRow.boundingBox();
expect(handleBox.width).toBeGreaterThanOrEqual(24);
const point = { x: handleBox.x + handleBox.width / 2, y: handleBox.y + handleBox.height / 2 };
const hitHandle = await page.evaluate(({ x, y }) => {
const hit = document.elementFromPoint(x, y);
return !!hit?.closest('.ge-layer-drag');
}, point);
expect(hitHandle).toBe(true);
const cdp = await page.context().newCDPSession(page);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: point.x, y: point.y, id: 1, radiusX: 8, radiusY: 8 }],
});
await page.waitForTimeout(450);
await expect(page.locator(`.ge-layer-item[data-layer-id="${dragId}"]`)).toHaveClass(/dragging/);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: point.x, y: topBox.y + 1, id: 1, radiusX: 8, radiusY: 8 }],
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
touchPoints: [],
});
await page.waitForTimeout(120);
expect((await editorState(page)).layers.map(layer => layer.id)).toEqual([targetId, dragId]);
} finally {
await context.close();
}
});
+375
View File
@@ -0,0 +1,375 @@
const { expect } = require('@playwright/test');
async function openBlankEditor(page, size = { width: 640, height: 480 }, name = 'Photo editor E2E') {
let lastError = null;
for (let pageAttempt = 0; pageAttempt < 3; pageAttempt += 1) {
try {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
await page.evaluate(async () => {
window.__photoEditorImport = async path => {
let lastImportError = null;
for (let attempt = 0; attempt < 4; attempt += 1) {
const url = attempt === 0 ? path : `${path}?e2e_retry=${attempt}-${Date.now()}`;
try { return await import(url); } catch (error) { lastImportError = error; }
await new Promise(resolve => setTimeout(resolve, 150 * (attempt + 1)));
}
throw lastImportError;
};
const gallery = await window.__photoEditorImport('/static/js/gallery.js?v=20260830editor4');
gallery.openGallery();
});
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
await page.locator('#gallery-editor-tab').click();
await page.evaluate(async ({ size: nextSize, name: nextName }) => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.openEditor(null, null, { w: nextSize.width, h: nextSize.height }, nextName);
}, { size, name });
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
lastError = null;
break;
} catch (error) {
lastError = error;
await page.goto('about:blank');
await page.waitForTimeout(250 * (pageAttempt + 1));
}
}
if (lastError) throw lastError;
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.layers.length;
})).toBe(2);
}
async function editorState(page) {
return page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const hashCanvas = canvas => {
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
let hash = 2166136261;
for (let index = 0; index < data.length; index += Math.max(4, Math.floor(data.length / 20_000 / 4) * 4)) {
const alpha = data[index + 3] / 255;
hash ^= Math.round(data[index] * alpha);
hash = Math.imul(hash, 16777619);
hash ^= Math.round(data[index + 1] * alpha);
hash = Math.imul(hash, 16777619);
hash ^= Math.round(data[index + 2] * alpha);
hash = Math.imul(hash, 16777619);
hash ^= data[index + 3];
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
};
const selectionBounds = canvas => {
if (!canvas) return null;
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
let left = canvas.width, top = canvas.height, right = -1, bottom = -1;
for (let y = 0; y < canvas.height; y += 1) for (let x = 0; x < canvas.width; x += 1) {
if (data[(y * canvas.width + x) * 4 + 3] < 1) continue;
left = Math.min(left, x); top = Math.min(top, y);
right = Math.max(right, x); bottom = Math.max(bottom, y);
}
return right < left ? null : { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
};
return {
documentRenderReady: !!state.documentRenderReady,
dimensions: [state.imgWidth, state.imgHeight],
activeLayerId: state.activeLayerId,
selectedLayerIds: [...(state.selectedLayerIds || [])],
groups: (state.layerGroups || []).map(group => ({
id: group.id,
name: group.name,
layerIds: [...group.layerIds],
parentId: group.parentId || null,
visible: group.visible !== false,
opacity: group.opacity,
blendMode: group.blendMode,
locked: !!group.locked,
collapsed: !!group.collapsed,
activeMaskId: group.activeMaskId || null,
effects: (group.effects || []).map(effect => ({
id: effect.id,
type: effect.type,
name: effect.name,
visible: effect.visible !== false,
opacity: effect.opacity,
params: JSON.parse(JSON.stringify(effect.params || {})),
})),
masks: (group.masks || []).map(mask => ({
id: mask.id,
name: mask.name,
visible: mask.visible !== false,
density: Number.isFinite(Number(mask.density)) ? mask.density : 1,
feather: Number.isFinite(Number(mask.feather)) ? mask.feather : 0,
mode: mask.mode,
size: [mask.canvas.width, mask.canvas.height],
pixelHash: hashCanvas(mask.canvas),
})),
})),
layers: state.layers.map(layer => ({
id: layer.id,
name: layer.name,
kind: layer.kind || 'raster',
effects: (layer.effects || []).map(effect => ({
id: effect.id,
type: effect.type,
name: effect.name,
visible: effect.visible !== false,
opacity: effect.opacity,
mask: effect.mask ? {
visible: effect.mask.visible !== false,
size: [effect.mask.canvas?.width || effect.mask.canvasW, effect.mask.canvas?.height || effect.mask.canvasH],
} : null,
params: JSON.parse(JSON.stringify(effect.params || {})),
})),
visible: layer.visible !== false,
opacity: layer.opacity,
locked: !!layer.locked,
locks: {
pixels: !!layer.locks?.pixels,
transparency: !!layer.locks?.transparency,
position: !!layer.locks?.position,
},
clipped: !!layer.clipped,
size: [layer.canvas.width, layer.canvas.height],
offset: state.layerOffsets.get(layer.id) || { x: 0, y: 0 },
pixelHash: hashCanvas(layer.canvas),
text: layer.text ? {
content: layer.text.content,
fontSize: layer.text.fontSize,
fontFamily: layer.text.fontFamily,
lineHeight: layer.text.lineHeight,
letterSpacing: layer.text.letterSpacing,
frameWidth: layer.text.frameWidth,
frameHeight: layer.text.frameHeight,
verticalAlign: layer.text.verticalAlign,
autoWidth: layer.text.autoWidth,
transform: { ...layer.text.transform },
} : null,
shape: layer.shape ? {
type: layer.shape.type,
width: layer.shape.width,
height: layer.shape.height,
fillColor: layer.shape.fillColor,
fillType: layer.shape.fillType,
gradientStart: layer.shape.gradientStart,
gradientMid: layer.shape.gradientMid,
gradientMidEnabled: layer.shape.gradientMidEnabled,
gradientMidPosition: layer.shape.gradientMidPosition,
gradientEnd: layer.shape.gradientEnd,
gradientStops: layer.shape.gradientStops ? layer.shape.gradientStops.map(stop => ({ ...stop })) : undefined,
gradientAngle: layer.shape.gradientAngle,
strokeColor: layer.shape.strokeColor,
strokeWidth: layer.shape.strokeWidth,
cornerRadius: layer.shape.cornerRadius,
sides: layer.shape.sides,
transform: { ...layer.shape.transform },
} : null,
adjustment: layer.adjustment ? JSON.parse(JSON.stringify(layer.adjustment)) : null,
placed: layer.kind === 'placed' && layer.placed?.sourceCanvas ? {
sourceSize: [layer.placed.sourceCanvas.width, layer.placed.sourceCanvas.height],
sourceName: layer.placed.sourceName,
matrix: [...layer.placed.matrix],
sourcePixelHash: hashCanvas(layer.placed.sourceCanvas),
} : null,
masks: (layer.masks || []).map(mask => ({
id: mask.id,
mode: mask.mode,
space: mask.space,
linked: mask.mode === 'layer' ? mask.linked !== false : true,
density: Number.isFinite(Number(mask.density)) ? mask.density : 1,
feather: Number.isFinite(Number(mask.feather)) ? mask.feather : 0,
offset: { x: Number(mask.offset?.x) || 0, y: Number(mask.offset?.y) || 0 },
size: [mask.canvas.width, mask.canvas.height],
pixelHash: hashCanvas(mask.canvas),
})),
})),
guides: state.guides,
draftId: state.draftId,
undo: state.undoStack.length,
redo: state.redoStack.length,
quickMaskActive: !!state.quickMaskActive,
selection: state.wandMask ? {
space: state.wandMaskSpace,
source: state.selectionSource,
size: [state.wandMask.width, state.wandMask.height],
pixelHash: hashCanvas(state.wandMask),
bounds: selectionBounds(state.wandMask),
} : null,
savedSelections: (state.savedSelections || []).map(selection => ({
id: selection.id,
name: selection.name,
size: [selection.canvas.width, selection.canvas.height],
pixelHash: hashCanvas(selection.canvas),
bounds: selectionBounds(selection.canvas),
})),
lastSelection: state.lastSelection?.canvas ? {
pixelHash: hashCanvas(state.lastSelection.canvas),
bounds: selectionBounds(state.lastSelection.canvas),
} : null,
persistIdle: !state.persistTimer && !state.persistInFlight,
};
});
}
async function dragOnCanvas(page, start, end) {
const box = await page.locator('.ge-main-canvas').boundingBox();
if (!box) throw new Error('Canvas has no visible bounds');
await page.mouse.move(box.x + box.width * start.x, box.y + box.height * start.y);
await page.mouse.down();
await page.mouse.move(box.x + box.width * end.x, box.y + box.height * end.y, { steps: 10 });
await page.mouse.up();
}
async function waitForDraft(page) {
await expect.poll(async () => {
const current = await editorState(page);
return !!current.draftId && current.persistIdle;
}, { timeout: 15_000 }).toBe(true);
return (await editorState(page)).draftId;
}
async function reopenDraft(page, draftId) {
await page.evaluate(async id => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
window.__galleryAllowCloseEditor = true;
editor.closeEditor();
await editor.openEditor(null, null, null, 'Reopened E2E document', id);
}, draftId);
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
await expect.poll(async () => (await editorState(page)).draftId).toBe(draftId);
await expect.poll(async () => (await editorState(page)).documentRenderReady, {
timeout: 20_000,
}).toBe(true);
}
async function openExportDialog(page) {
await page.locator('#ge-save-menu-btn').click();
await page.locator('#ge-download').click();
await expect(page.locator('.ge-export-dialog')).toBeVisible();
}
async function flattenedPixelDigest(page, size = null) {
return page.evaluate(async targetSize => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
const image = new Image();
image.src = editor.exportPNG();
await image.decode();
const canvas = document.createElement('canvas');
canvas.width = targetSize?.width || image.naturalWidth;
canvas.height = targetSize?.height || image.naturalHeight;
const context = canvas.getContext('2d');
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(image, 0, 0, canvas.width, canvas.height);
const raw = context.getImageData(0, 0, canvas.width, canvas.height).data;
const pixels = new Uint8ClampedArray(raw.length);
for (let index = 0; index < raw.length; index += 4) {
const alpha = raw[index + 3] / 255;
pixels[index] = Math.round(raw[index] * alpha);
pixels[index + 1] = Math.round(raw[index + 1] * alpha);
pixels[index + 2] = Math.round(raw[index + 2] * alpha);
pixels[index + 3] = raw[index + 3];
}
const digest = await crypto.subtle.digest('SHA-256', pixels);
return {
width: canvas.width,
height: canvas.height,
sha256: Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''),
};
}, size);
}
async function encodedImagePixelDigest(page, bytes) {
return page.evaluate(async base64 => {
const binary = atob(base64);
const encoded = Uint8Array.from(binary, character => character.charCodeAt(0));
const bitmap = await createImageBitmap(new Blob([encoded], { type: 'image/png' }));
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext('2d');
context.drawImage(bitmap, 0, 0);
bitmap.close();
const raw = context.getImageData(0, 0, canvas.width, canvas.height).data;
const pixels = new Uint8ClampedArray(raw.length);
for (let index = 0; index < raw.length; index += 4) {
const alpha = raw[index + 3] / 255;
pixels[index] = Math.round(raw[index] * alpha);
pixels[index + 1] = Math.round(raw[index + 1] * alpha);
pixels[index + 2] = Math.round(raw[index + 2] * alpha);
pixels[index + 3] = raw[index + 3];
}
const digest = await crypto.subtle.digest('SHA-256', pixels);
return {
width: canvas.width,
height: canvas.height,
sha256: Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''),
};
}, bytes.toString('base64'));
}
async function compareExportPixels(page, bytes, size) {
return page.evaluate(async ({ base64, targetSize }) => {
const decode = async source => {
const image = new Image();
image.src = source;
await image.decode();
return image;
};
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
const expectedImage = await decode(editor.exportPNG());
const binary = atob(base64);
const actualImage = await decode(URL.createObjectURL(new Blob([
Uint8Array.from(binary, character => character.charCodeAt(0)),
], { type: 'image/png' })));
const pixels = image => {
const canvas = document.createElement('canvas');
canvas.width = targetSize.width;
canvas.height = targetSize.height;
const context = canvas.getContext('2d');
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(image, 0, 0, canvas.width, canvas.height);
return context.getImageData(0, 0, canvas.width, canvas.height).data;
};
const expected = pixels(expectedImage);
const actual = pixels(actualImage);
URL.revokeObjectURL(actualImage.src);
let total = 0;
let maximum = 0;
let changedPixels = 0;
for (let index = 0; index < expected.length; index += 4) {
const expectedAlpha = expected[index + 3] / 255;
const actualAlpha = actual[index + 3] / 255;
let pixelMaximum = Math.abs(expected[index + 3] - actual[index + 3]);
total += pixelMaximum;
for (let channel = 0; channel < 3; channel += 1) {
const delta = Math.abs(
expected[index + channel] * expectedAlpha - actual[index + channel] * actualAlpha,
);
total += delta;
pixelMaximum = Math.max(pixelMaximum, delta);
}
maximum = Math.max(maximum, pixelMaximum);
if (pixelMaximum > 1) changedPixels += 1;
}
return {
meanAbsoluteError: total / expected.length,
maximumError: maximum,
changedPixelRatio: changedPixels / (expected.length / 4),
};
}, { base64: bytes.toString('base64'), targetSize: size });
}
module.exports = {
compareExportPixels,
dragOnCanvas,
encodedImagePixelDigest,
editorState,
flattenedPixelDigest,
openBlankEditor,
openExportDialog,
reopenDraft,
waitForDraft,
};
@@ -0,0 +1,57 @@
const { test, expect } = require('@playwright/test');
const { editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
async function sampleComposite(page) {
return page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const ctx = state.documentCompositeCanvas.getContext('2d');
return {
left: [...ctx.getImageData(40, 50, 1, 1).data],
right: [...ctx.getImageData(160, 50, 1, 1).data],
};
});
}
test('clipping mask uses base alpha and survives undo, redo, and server reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 200, height: 100 }, 'Clipping E2E');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const base = state.layers.find(layer => layer.name === 'Background');
const color = state.layers.find(layer => layer.name === 'Edit');
for (const layer of [base, color]) layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
base.ctx.fillStyle = '#ff0000';
base.ctx.fillRect(0, 0, 100, 100);
color.ctx.fillStyle = '#0000ff';
color.ctx.fillRect(0, 0, 200, 100);
});
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
await expect(editRow).toHaveClass(/clipped/);
await expect(editRow.locator('.ge-layer-clipped-marker')).toBeVisible();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').clipped).toBe(true);
let pixels = await sampleComposite(page);
expect(pixels.left).toEqual([0, 0, 255, 255]);
expect(pixels.right[3]).toBe(0);
await page.locator('#ge-undo').click();
expect((await sampleComposite(page)).right).toEqual([0, 0, 255, 255]);
await page.locator('#ge-redo').click();
expect((await sampleComposite(page)).right[3]).toBe(0);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').clipped).toBe(true);
expect((await sampleComposite(page)).right[3]).toBe(0);
await expect(page.locator('.ge-layer-item.clipped .ge-layer-clipped-marker')).toBeVisible();
await page.locator('#ge-layer-tools button[title="Merge down into layer below"]').click();
expect((await editorState(page)).layers).toHaveLength(1);
expect((await sampleComposite(page)).right[3]).toBe(0);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').clipped).toBe(true);
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
expect((await sampleComposite(page)).right).toEqual([0, 0, 255, 255]);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,65 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
test('independent layer locks enforce operations and survive undo and reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 360, height: 260 }, 'Layer locks E2E');
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
const lockButton = editRow.locator('.ge-layer-lock-btn');
const opacityRow = editRow.locator('.ge-layer-opacity-row');
const lockBox = await lockButton.boundingBox();
const opacityBox = await opacityRow.boundingBox();
expect(lockBox).toBeTruthy();
expect(opacityBox).toBeTruthy();
expect(lockBox.y + lockBox.height).toBeLessThanOrEqual(opacityBox.y + 1);
const toggleLock = async type => {
await lockButton.click();
const menu = page.locator('#ge-layer-lock-menu');
await expect(menu).toBeVisible();
await menu.locator(`[data-lock-type="${type}"]`).click();
};
await toggleLock('position');
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').locks.position).toBe(true);
await expect(lockButton).toHaveClass(/active/);
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await dragOnCanvas(page, { x: 0.35, y: 0.35 }, { x: 0.55, y: 0.50 });
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').offset).toEqual({ x: 0, y: 0 });
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').locks.position).toBe(false);
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').locks.position).toBe(true);
await toggleLock('position');
await toggleLock('pixels');
const emptyHash = (await editorState(page)).layers.find(layer => layer.name === 'Edit').pixelHash;
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.42, y: 0.42 }, { x: 0.58, y: 0.50 });
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').pixelHash).toBe(emptyHash);
await toggleLock('pixels');
await toggleLock('transparency');
await dragOnCanvas(page, { x: 0.42, y: 0.42 }, { x: 0.58, y: 0.50 });
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').pixelHash).toBe(emptyHash);
await toggleLock('transparency');
await dragOnCanvas(page, { x: 0.42, y: 0.42 }, { x: 0.58, y: 0.50 });
expect((await editorState(page)).layers.find(layer => layer.name === 'Edit').pixelHash).not.toBe(emptyHash);
await toggleLock('pixels');
await toggleLock('transparency');
await toggleLock('position');
const beforeReopen = await editorState(page);
const expectedLocks = beforeReopen.layers.find(layer => layer.name === 'Edit').locks;
expect(expectedLocks).toEqual({ pixels: true, transparency: true, position: true });
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(layer => layer.name === 'Edit').locks).toEqual(expectedLocks);
await expect(page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first().locator('.ge-layer-lock-btn')).toHaveClass(/active/);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,141 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers');
test('layer mask link controls independent movement and survives reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Linked layer mask');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.2, y: 0.35 }, { x: 0.75, y: 0.6 });
let current = await editorState(page);
const layerId = current.activeLayerId;
const layerRow = page.locator(`.ge-layer-item[data-layer-id="${layerId}"]`);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
await expect(maskRow).toBeVisible();
current = await editorState(page);
const originalLayer = current.layers.find(layer => layer.id === layerId);
expect(originalLayer.masks[0].linked).toBe(true);
expect(originalLayer.masks[0].offset).toEqual({ x: 0, y: 0 });
const originalMaskHash = originalLayer.masks[0].pixelHash;
await maskRow.locator('button[title="Invert mask"]').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === layerId).masks[0].pixelHash)
.not.toBe(originalMaskHash);
await page.locator('#ge-undo').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === layerId).masks[0].pixelHash)
.toBe(originalMaskHash);
await maskRow.locator('details.ge-mask-properties > summary').click();
await maskRow.locator('input.ge-mask-density').fill('50');
await maskRow.locator('input.ge-mask-density').dispatchEvent('change');
current = await editorState(page);
expect(current.layers.find(layer => layer.id === layerId).masks[0].density).toBe(0.5);
await maskRow.locator('input.ge-mask-feather').fill('12');
await maskRow.locator('input.ge-mask-feather').dispatchEvent('change');
current = await editorState(page);
expect(current.layers.find(layer => layer.id === layerId).masks[0].feather).toBe(12);
await page.locator('#ge-undo').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === layerId).masks[0].density).toBe(1);
expect(current.layers.find(layer => layer.id === layerId).masks[0].feather).toBe(0);
await maskRow.locator('button[title="Inspect mask"]').click();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.maskInspectMode;
})).toBe(true);
const inspectedPixel = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return [...state.mainCtx.getImageData(Math.floor(state.imgWidth / 2), Math.floor(state.imgHeight / 2), 1, 1).data];
});
expect(inspectedPixel[0]).toBeGreaterThan(200);
expect(inspectedPixel[1]).toBeGreaterThan(200);
expect(inspectedPixel[2]).toBeGreaterThan(200);
await page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' })
.locator('button[title="Inspect mask"]').click();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.maskInspectMode;
})).toBe(false);
await maskRow.locator('.ge-mask-link-btn').click();
await expect(maskRow.locator('.ge-mask-link-btn')).toHaveAttribute('aria-pressed', 'false');
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await dragOnCanvas(page, { x: 0.45, y: 0.45 }, { x: 0.55, y: 0.5 });
current = await editorState(page);
let layer = current.layers.find(item => item.id === layerId);
expect(layer.offset).toEqual(originalLayer.offset);
expect(layer.pixelHash).toBe(originalLayer.pixelHash);
expect(layer.masks[0].linked).toBe(false);
expect(layer.masks[0].offset.x).toBeGreaterThan(0);
expect(layer.masks[0].offset.y).toBeGreaterThan(0);
const fixedDocumentMask = {
x: layer.offset.x + layer.masks[0].offset.x,
y: layer.offset.y + layer.masks[0].offset.y,
};
await layerRow.click();
await dragOnCanvas(page, { x: 0.4, y: 0.4 }, { x: 0.5, y: 0.45 });
current = await editorState(page);
layer = current.layers.find(item => item.id === layerId);
expect(layer.offset.x).toBeGreaterThan(originalLayer.offset.x);
expect(layer.offset.y).toBeGreaterThan(originalLayer.offset.y);
expect({
x: layer.offset.x + layer.masks[0].offset.x,
y: layer.offset.y + layer.masks[0].offset.y,
}).toEqual(fixedDocumentMask);
const beforeTransform = layer;
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await page.locator('#ge-transform-w').fill('280');
await page.locator('#ge-transform-w').dispatchEvent('input');
await page.locator('#ge-transform-apply').click();
current = await editorState(page);
layer = current.layers.find(item => item.id === layerId);
expect(layer.size[0]).toBe(280);
expect({
x: layer.offset.x + layer.masks[0].offset.x,
y: layer.offset.y + layer.masks[0].offset.y,
}).toEqual(fixedDocumentMask);
await page.locator('#ge-undo').click();
await expect.poll(async () => {
const state = await editorState(page);
return state.layers.find(item => item.id === layerId).size[0];
}).toBe(beforeTransform.size[0]);
await maskRow.locator('.ge-mask-link-btn').click();
current = await editorState(page);
const relinked = current.layers.find(item => item.id === layerId);
expect(relinked.masks[0].linked).toBe(true);
const linkedRelativeOffset = { ...relinked.masks[0].offset };
await layerRow.click();
await dragOnCanvas(page, { x: 0.35, y: 0.35 }, { x: 0.45, y: 0.35 });
current = await editorState(page);
layer = current.layers.find(item => item.id === layerId);
expect(layer.masks[0].offset).toEqual(linkedRelativeOffset);
await page.locator('#ge-undo').click();
await expect.poll(async () => {
const state = await editorState(page);
return state.layers.find(item => item.id === layerId).offset.x;
}).toBe(relinked.offset.x);
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(item => item.id === layerId)).toEqual(
beforeReopen.layers.find(item => item.id === layerId),
);
const maskRowAfterReopen = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
await maskRowAfterReopen.locator('details.ge-mask-properties > summary').click();
await maskRowAfterReopen.locator('button[title="Bake this mask into the layer and remove the mask"]').click();
await expect.poll(async () => (await editorState(page)).layers.find(item => item.id === layerId).masks).toHaveLength(0);
await page.locator('#ge-undo').click();
await expect.poll(async () => (await editorState(page)).layers.find(item => item.id === layerId).masks).toHaveLength(1);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,304 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
test('selected layers align to the canvas and undo as one operation', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Alignment E2E');
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const edit = state.layers.find(layer => layer.name === 'Edit');
const added = state.layers.find(layer => layer.id === state.activeLayerId);
edit.canvas.width = 100;
edit.canvas.height = 80;
added.canvas.width = 50;
added.canvas.height = 40;
state.layerOffsets.set(edit.id, { x: 12, y: 18 });
state.layerOffsets.set(added.id, { x: 190, y: 150 });
});
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
await editRow.click({ modifiers: ['Control'] });
await expect(page.locator('.ge-layer-item.selected[data-layer-id]')).toHaveCount(2);
await page.locator('#ge-selected-align').click();
await page.locator('#ge-layer-align-menu button').filter({ hasText: 'Align center' }).click();
let current = await editorState(page);
expect(current.layers.find(layer => layer.name === 'Edit').offset).toEqual({ x: 110, y: 18 });
expect(current.layers.find(layer => layer.name !== 'Background' && layer.name !== 'Edit').offset)
.toEqual({ x: 135, y: 150 });
await page.locator('#ge-undo').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.name === 'Edit').offset).toEqual({ x: 12, y: 18 });
expect(current.layers.find(layer => layer.name !== 'Background' && layer.name !== 'Edit').offset)
.toEqual({ x: 190, y: 150 });
});
test('layer rename cancels on Escape without committing the draft name', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 160 }, 'Rename cancel E2E');
const row = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
const rowId = await row.getAttribute('data-layer-id');
const name = row.locator('.ge-layer-name');
const original = await name.textContent();
await name.dblclick();
const stableRow = page.locator(`.ge-layer-item[data-layer-id="${rowId}"]`);
const input = stableRow.locator('.ge-layer-name-input');
await input.fill('Temporary name');
await input.press('Escape');
await expect(stableRow.locator('.ge-layer-name')).toHaveText(original);
await expect(stableRow.locator('.ge-layer-name-input')).toHaveCount(0);
});
test('layer rows can be selected with Enter and Space', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 160 }, 'Keyboard layer selection E2E');
const background = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
const edit = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
await background.focus();
await page.keyboard.press('Enter');
await expect(background).toHaveAttribute('aria-pressed', 'true');
await expect(edit).toHaveAttribute('aria-pressed', 'false');
await edit.focus();
await page.keyboard.press('Space');
await expect(edit).toHaveAttribute('aria-pressed', 'true');
await expect(background).toHaveAttribute('aria-pressed', 'false');
});
test('group rows can be selected with the keyboard', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 160 }, 'Keyboard group selection E2E');
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
const group = page.locator('.ge-layer-group-row').first();
await group.focus();
await page.keyboard.press('Enter');
await expect(group).toHaveAttribute('aria-pressed', 'true');
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.activeGroupId;
})).toBe(await group.getAttribute('data-group-id'));
});
test('Delete removes the selected layer and undo restores it', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 160 }, 'Keyboard delete E2E');
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
const addedId = (await editorState(page)).activeLayerId;
await page.locator(`.ge-layer-item[data-layer-id="${addedId}"]`).click();
await page.keyboard.press('Backspace');
expect((await editorState(page)).layers.some(layer => layer.id === addedId)).toBe(false);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.some(layer => layer.id === addedId)).toBe(true);
});
test('Ctrl/Cmd+J duplicates the active layer through the layer panel path', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 160 }, 'Keyboard duplicate E2E');
const before = await editorState(page);
const activeId = before.activeLayerId;
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+j' : 'Control+j');
const after = await editorState(page);
expect(after.layers).toHaveLength(before.layers.length + 1);
expect(after.activeLayerId).not.toBe(activeId);
expect(after.layers.find(layer => layer.id === after.activeLayerId).name).toContain('copy');
});
test('move tool can auto-select the topmost visible layer under the pointer', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Auto-select E2E');
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.75, y: 0.75 });
const shape = (await editorState(page)).layers.find(layer => layer.kind === 'shape');
expect(shape).toBeTruthy();
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await expect(page.locator('.ge-auto-select-option')).toBeVisible();
await page.locator('.ge-auto-select-option').click();
await expect(page.locator('#ge-auto-select-layer')).toBeChecked();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await expect(page.locator('.ge-auto-select-option')).toBeHidden();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await expect(page.locator('.ge-auto-select-option')).toBeVisible();
const box = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(box.x + box.width * 0.5, box.y + box.height * 0.5);
await expect.poll(async () => (await editorState(page)).activeLayerId).toBe(shape.id);
expect((await editorState(page)).selectedLayerIds).toEqual([shape.id]);
});
test('multi-selected layers move together and support history-backed bulk actions', async ({ page }) => {
await openBlankEditor(page);
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
await backgroundRow.click({ modifiers: ['Control'] });
await expect(page.locator('.ge-layer-item.selected[data-layer-id]')).toHaveCount(2);
expect((await editorState(page)).selectedLayerIds).toHaveLength(2);
await expect(page.locator('#ge-layer-selection-bar')).toBeVisible();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await dragOnCanvas(page, { x: 0.42, y: 0.42 }, { x: 0.52, y: 0.50 });
let current = await editorState(page);
const edit = current.layers.find(layer => layer.name === 'Edit');
const background = current.layers.find(layer => layer.name === 'Background');
expect(edit.offset).toEqual(background.offset);
expect(edit.offset).not.toEqual({ x: 0, y: 0 });
await page.locator('#ge-undo').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.name === 'Edit').offset).toEqual({ x: 0, y: 0 });
expect(current.layers.find(layer => layer.name === 'Background').offset).toEqual({ x: 0, y: 0 });
expect(current.selectedLayerIds).toHaveLength(2);
await page.locator('#ge-redo').click();
await page.locator('#ge-selected-visibility').click();
current = await editorState(page);
expect(current.layers.every(layer => layer.visible === false)).toBe(true);
await page.locator('#ge-undo').click();
current = await editorState(page);
expect(current.layers.every(layer => layer.visible !== false)).toBe(true);
await page.locator('#ge-selected-lock').click();
current = await editorState(page);
expect(current.layers.every(layer => layer.locked)).toBe(true);
await page.locator('#ge-undo').click();
await editRow.click();
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
const addedId = (await editorState(page)).activeLayerId;
await editRow.click({ modifiers: ['Control'] });
await expect(page.locator('.ge-layer-item.selected[data-layer-id]')).toHaveCount(2);
await page.locator('#ge-selected-delete').click();
current = await editorState(page);
expect(current.layers).toHaveLength(1);
expect(current.layers[0].name).toBe('Background');
expect(current.layers.some(layer => layer.id === addedId)).toBe(false);
await page.locator('#ge-undo').click();
current = await editorState(page);
expect(current.layers).toHaveLength(3);
expect(current.selectedLayerIds).toHaveLength(2);
});
test('layer groups composite, lock, collapse, undo, and survive server reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 480, height: 320 }, 'Grouped E2E');
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
await backgroundRow.click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
await expect(page.locator('.ge-layer-group-row')).toHaveCount(1);
await expect(page.locator('.ge-layer-group-row .ge-group-inline-thumb')).toHaveCount(1);
let current = await editorState(page);
expect(current.groups).toHaveLength(1);
expect(current.groups[0].layerIds).toHaveLength(2);
const groupName = page.locator('.ge-layer-group-name');
await groupName.dblclick();
await page.locator('.ge-layer-group-row input.ge-layer-name-input').fill('Hero group');
await page.locator('.ge-layer-group-row input.ge-layer-name-input').press('Enter');
await page.locator('.ge-layer-group-row .ge-layer-opacity').fill('55');
await page.locator('.ge-layer-group-toggle').click();
await expect(page.locator('.ge-layer-item.grouped')).toHaveCount(0);
current = await editorState(page);
expect(current.groups[0]).toMatchObject({ name: 'Hero group', opacity: 0.55, collapsed: true });
await page.locator('.ge-layer-group-row .ge-layer-lock-btn').click();
const beforeLockNudge = await editorState(page);
await page.keyboard.press('ArrowRight');
expect((await editorState(page)).layers.map(layer => layer.offset)).toEqual(beforeLockNudge.layers.map(layer => layer.offset));
await page.locator('.ge-layer-group-row .ge-layer-lock-btn').click();
await page.locator('.ge-layer-group-row').click();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await dragOnCanvas(page, { x: 0.40, y: 0.40 }, { x: 0.52, y: 0.48 });
current = await editorState(page);
expect(current.layers[0].offset).toEqual(current.layers[1].offset);
expect(current.layers[0].offset).not.toEqual({ x: 0, y: 0 });
await page.locator('.ge-layer-group-row .ge-layer-vis').click();
current = await editorState(page);
expect(current.groups[0].visible).toBe(false);
expect(current.layers.every(layer => layer.visible)).toBe(true);
await page.locator('#ge-undo').click();
expect((await editorState(page)).groups[0].visible).toBe(true);
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.groups).toEqual(beforeReopen.groups);
expect(reopened.layers.map(layer => layer.offset)).toEqual(beforeReopen.layers.map(layer => layer.offset));
await expect(page.locator('.ge-layer-group-row')).toHaveCount(1);
await expect(page.locator('.ge-layer-group-row .ge-group-inline-thumb')).toHaveCount(1);
await expect(page.locator('.ge-layer-item.grouped')).toHaveCount(0);
await page.locator('.ge-layer-group-row').click();
await page.locator('#ge-layer-tools button[title="Ungroup layers"]').click();
expect((await editorState(page)).groups).toHaveLength(0);
await page.locator('#ge-undo').click();
expect((await editorState(page)).groups).toHaveLength(1);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('nested groups preserve hierarchy, ancestor locks, collapse, and server reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 420, height: 300 }, 'Nested groups E2E');
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
await backgroundRow.click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
let current = await editorState(page);
const innerId = current.groups[0].id;
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
current = await editorState(page);
const looseId = current.activeLayerId;
await page.locator(`.ge-layer-group-row[data-group-id="${innerId}"]`).click();
await page.locator(`.ge-layer-item[data-layer-id="${looseId}"]`).click({ modifiers: ['Control'] });
await expect(page.locator('.ge-layer-item.selected[data-layer-id]')).toHaveCount(3);
await page.locator('#ge-group-selected').click();
current = await editorState(page);
expect(current.groups).toHaveLength(2);
const inner = current.groups.find(group => group.id === innerId);
const outer = current.groups.find(group => group.id !== innerId);
expect(inner.parentId).toBe(outer.id);
expect(outer.layerIds).toEqual([looseId]);
expect(inner.layerIds).toHaveLength(2);
await expect(page.locator('.ge-layer-group-row')).toHaveCount(2);
await expect(page.locator('.ge-layer-group-row .ge-group-inline-thumb')).toHaveCount(2);
await expect(page.locator(`.ge-layer-group-row[data-group-id="${inner.id}"]`)).toHaveCSS('--group-depth', '1');
const outerRow = page.locator(`.ge-layer-group-row[data-group-id="${outer.id}"]`);
await outerRow.locator('.ge-layer-group-toggle').click();
await expect(page.locator('.ge-layer-group-row')).toHaveCount(1);
await expect(page.locator('.ge-layer-item.grouped')).toHaveCount(0);
await outerRow.locator('.ge-layer-group-toggle').click();
await expect(page.locator('.ge-layer-group-row')).toHaveCount(2);
await outerRow.locator('.ge-layer-lock-btn').click();
await page.locator(`.ge-layer-item[data-layer-id="${inner.layerIds[0]}"]`).click();
const beforeNudge = await editorState(page);
await page.keyboard.press('ArrowRight');
expect((await editorState(page)).layers.map(layer => layer.offset)).toEqual(beforeNudge.layers.map(layer => layer.offset));
await outerRow.locator('.ge-layer-lock-btn').click();
const draftId = await waitForDraft(page);
const beforeReopen = await editorState(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.groups).toEqual(beforeReopen.groups);
await expect(page.locator('.ge-layer-group-row')).toHaveCount(2);
await page.locator(`.ge-layer-group-row[data-group-id="${outer.id}"]`).click();
await page.locator('#ge-layer-tools button[title="Ungroup layers"]').click();
current = await editorState(page);
expect(current.groups).toHaveLength(1);
expect(current.groups[0]).toMatchObject({ id: inner.id, parentId: null });
expect(current.groups[0].layerIds).toHaveLength(2);
await expect(page.locator('.ge-layer-item.grouped')).toHaveCount(2);
await page.locator('#ge-undo').click();
expect((await editorState(page)).groups).toEqual(beforeReopen.groups);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,48 @@
const { test, expect } = require('@playwright/test');
const { editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
async function seedVisibleLayers(page) {
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const background = state.layers.find(layer => layer.name === 'Background');
const edit = state.layers.find(layer => layer.name === 'Edit');
background.ctx.fillStyle = '#26354f';
background.ctx.fillRect(0, 0, background.canvas.width, background.canvas.height);
edit.ctx.fillStyle = '#cf5b4a';
edit.ctx.fillRect(48, 32, edit.canvas.width - 96, edit.canvas.height - 64);
window.galleryEditorComposite?.();
});
}
test('Merge all preserves retained effects and adjustment output', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Merge fidelity E2E');
await seedVisibleLayers(page);
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-color-overlay"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[type="color"]').fill('#f0c04a');
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click();
await expect(page.locator('.ge-adj-popup')).toBeVisible();
await page.locator('.ge-adj-row input[data-key="exposure"]').fill('45');
await page.locator('.ge-adj-row input[data-key="exposure"]').dispatchEvent('input');
await page.locator('[data-adj-action="ok"]').click();
const before = await flattenedPixelDigest(page);
const beforeState = await editorState(page);
expect(beforeState.layers.some(layer => layer.effects.length)).toBe(true);
expect(beforeState.layers.some(layer => layer.kind === 'adjustment')).toBe(true);
await page.locator('#ge-merge-all').click();
await expect.poll(async () => (await editorState(page)).layers.length).toBe(1);
const afterState = await editorState(page);
expect(afterState.layers[0].effects).toHaveLength(0);
expect(afterState.layers).toEqual([
expect.objectContaining({ kind: 'raster', effects: [] }),
]);
expect(await flattenedPixelDigest(page)).toEqual(before);
});
@@ -0,0 +1,212 @@
const { test, expect } = require('@playwright/test');
const fs = require('node:fs');
const { editorState, openBlankEditor, waitForDraft } = require('./helpers');
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
test('mobile tool and layer sheets do not overlap mask controls', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile layer sheet');
await page.locator('.ge-tour-close').click({ timeout: 500 }).catch(() => {});
const controls = page.locator('.ge-controls');
const layerSheet = page.locator('.ge-right-panel');
await expect(controls).toHaveClass(/dismissed/);
await expect(layerSheet).not.toHaveClass(/minimized/);
await page.locator('.ge-layers-title').click();
await expect(layerSheet).toHaveClass(/expanded/);
const layerId = (await editorState(page)).activeLayerId;
const layerRow = page.locator(`.ge-layer-item[data-layer-id="${layerId}"]`);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
const maskName = maskRow.locator('.ge-layer-name');
const maskThumb = maskRow.locator('.ge-mask-inline-thumb');
await expect(maskRow).toBeVisible();
await expect(maskName).toContainText('Layer Mask');
await expect(maskThumb).toBeVisible();
const rowBox = await maskRow.boundingBox();
const nameBox = await maskName.boundingBox();
const linkBox = await maskRow.locator('.ge-mask-link-btn').boundingBox();
expect(rowBox).toBeTruthy();
expect(nameBox.width).toBeGreaterThan(80);
expect(rowBox.x).toBeGreaterThanOrEqual(0);
expect(rowBox.x + rowBox.width).toBeLessThanOrEqual(390);
expect(nameBox.x).toBeGreaterThanOrEqual(0);
expect(nameBox.x + nameBox.width).toBeLessThanOrEqual(linkBox.x);
expect(await page.locator('.ge-layers-list').evaluate(list => list.scrollLeft)).toBe(0);
await maskRow.locator('.ge-mask-link-btn').click();
await expect(maskRow.locator('.ge-mask-link-btn')).toHaveAttribute('aria-pressed', 'false');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await expect(controls).not.toHaveClass(/dismissed/);
await expect(layerSheet).toHaveClass(/minimized/);
const controlsBox = await controls.boundingBox();
const sheetBox = await layerSheet.boundingBox();
expect(controlsBox.y).toBeLessThan(sheetBox.y + sheetBox.height);
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
await expect(controls).toHaveClass(/dismissed/);
});
test('mobile group rows keep their preview and touch controls inside the viewport', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile group preview');
await page.locator('.ge-layers-title').click();
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
await backgroundRow.click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
const groupRow = page.locator('.ge-layer-group-row').first();
await expect(groupRow).toBeVisible();
await expect(groupRow.locator('.ge-group-inline-thumb')).toBeVisible();
const rowBox = await groupRow.boundingBox();
const thumbBox = await groupRow.locator('.ge-group-inline-thumb').boundingBox();
const toggleBox = await groupRow.locator('.ge-layer-group-toggle').boundingBox();
const visibilityBox = await groupRow.locator('.ge-layer-vis').boundingBox();
expect(rowBox).toBeTruthy();
for (const box of [thumbBox, toggleBox, visibilityBox]) {
expect(box).toBeTruthy();
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(390);
}
});
test('mobile touch mask editing supports undo and persists through reload', async ({ page, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile mask persistence');
await page.locator('.ge-layers-title').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
await expect(maskRow).toBeVisible();
const before = (await editorState(page)).layers.find(layer => layer.masks.length).masks[0].pixelHash;
// A new layer mask is fully white (revealed), so Brush would paint white
// onto white. Eraser makes the first touch edit observable by hiding pixels.
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
// Collapse the bottom-sheet controls so the full canvas is available to the
// touch gesture, matching the canvas-first mobile editing mode.
await page.locator('.ge-tool-btn[data-tool="eraser"]').click();
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
const cdp = await page.context().newCDPSession(page);
const visibleCanvasPoint = await page.evaluate(() => {
const canvas = document.querySelector('.ge-main-canvas');
const rect = canvas?.getBoundingClientRect();
if (!canvas || !rect) return null;
for (let y = rect.top + 8; y < rect.bottom - 8; y += 8) {
for (let x = rect.left + 8; x < rect.right - 8; x += 8) {
if (document.elementFromPoint(x, y) === canvas) return { x, y };
}
}
return null;
});
expect(visibleCanvasPoint).toBeTruthy();
const x1 = visibleCanvasPoint.x;
const x2 = Math.min(canvasBox.x + canvasBox.width - 8, x1 + canvasBox.width * 0.35);
const y = visibleCanvasPoint.y;
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: x1, y, id: 1, radiusX: 6, radiusY: 6 }],
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: x2, y, id: 1, radiusX: 6, radiusY: 6 }],
});
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
const painted = await editorState(page);
const paintedLayer = painted.layers.find(layer => layer.masks.length);
expect(paintedLayer.masks[0].pixelHash).not.toBe(before);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.masks.length).masks[0].pixelHash)
.toBe(before);
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers.find(layer => layer.masks.length).masks[0].pixelHash)
.toBe(paintedLayer.masks[0].pixelHash);
const draftId = await waitForDraft(page);
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page.locator('.ge-main-canvas')).toBeVisible({ timeout: 20_000 });
await expect.poll(async () => {
const state = await editorState(page);
return state.layers.find(layer => layer.masks.length)?.masks[0]?.pixelHash;
}).toBe(paintedLayer.masks[0].pixelHash);
await page.request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('mobile export dialog stays usable and downloads the requested PNG', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile export');
await page.locator('#ge-save-menu-btn').click();
await page.locator('#ge-download').click();
const dialog = page.locator('.ge-export-dialog');
await expect(dialog).toBeVisible();
const dialogBox = await dialog.boundingBox();
expect(dialogBox).toBeTruthy();
expect(dialogBox.x).toBeGreaterThanOrEqual(0);
expect(dialogBox.y).toBeGreaterThanOrEqual(0);
expect(dialogBox.x + dialogBox.width).toBeLessThanOrEqual(390);
expect(dialogBox.y + dialogBox.height).toBeLessThanOrEqual(844);
await page.locator('#ge-export-width').fill('160');
await page.locator('#ge-export-filename').fill('mobile-export');
const downloadPromise = page.waitForEvent('download');
await dialog.locator('button[type="submit"]').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('mobile-export.png');
const bytes = fs.readFileSync(await download.path());
expect(bytes.subarray(1, 4).toString('ascii')).toBe('PNG');
expect(bytes.readUInt32BE(16)).toBe(160);
expect(bytes.readUInt32BE(20)).toBe(120);
});
test('mobile touch moves an unlinked layer mask without moving its layer', async ({ page, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile mask movement');
await page.locator('.ge-layers-title').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
const layerId = (await editorState(page)).activeLayerId;
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
const maskRow = page.locator('.ge-mask-sub-item').filter({ hasText: 'Layer Mask' });
await expect(maskRow).toBeVisible();
await maskRow.locator('.ge-mask-link-btn').click();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
await page.locator('.ge-tool-btn[data-tool="move"]').click();
const point = await page.evaluate(() => {
const target = document.querySelector('.ge-main-canvas');
const rect = target?.getBoundingClientRect();
if (!target || !rect) return null;
for (let y = rect.top + 8; y < rect.bottom - 8; y += 8) {
for (let x = rect.left + 8; x < rect.right - 8; x += 8) {
if (document.elementFromPoint(x, y) === target) return { x, y };
}
}
return null;
});
expect(point).toBeTruthy();
const before = await editorState(page);
const cdp = await page.context().newCDPSession(page);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: point.x, y: point.y, id: 1, radiusX: 6, radiusY: 6 }],
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: point.x + 24, y: point.y + 16, id: 1, radiusX: 6, radiusY: 6 }],
});
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
const moved = await editorState(page);
const beforeLayer = before.layers.find(layer => layer.id === layerId);
const movedLayer = moved.layers.find(layer => layer.id === layerId);
expect(movedLayer.offset).toEqual(beforeLayer.offset);
expect(movedLayer.masks[0].offset.x).not.toBe(beforeLayer.masks[0].offset.x);
expect(movedLayer.masks[0].offset.y).not.toBe(beforeLayer.masks[0].offset.y);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.id === layerId).masks[0].offset)
.toEqual(beforeLayer.masks[0].offset);
});
@@ -0,0 +1,127 @@
const { test, expect } = require('@playwright/test');
const fs = require('node:fs/promises');
const { editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
function selectionBounds(layers) {
const left = Math.min(...layers.map(layer => layer.offset.x));
const top = Math.min(...layers.map(layer => layer.offset.y));
const right = Math.max(...layers.map(layer => layer.offset.x + layer.size[0]));
const bottom = Math.max(...layers.map(layer => layer.offset.y + layer.size[1]));
return { left, top, right, bottom, width: right - left, height: bottom - top, centerX: (left + right) / 2, centerY: (top + bottom) / 2 };
}
test('shared transform preserves relative layout, masks, retained text, undo, cancel, and reopen', async ({ page, request }) => {
await openBlankEditor(page, { width: 400, height: 300 }, 'Multi-transform E2E');
const editRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Edit' }).first();
await editRow.click();
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-tool-btn[data-tool="text"]').click();
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(canvasBox.x + canvasBox.width * 0.35, canvasBox.y + canvasBox.height * 0.25);
await page.locator('#ge-text-content').fill('Transform together');
await page.locator('#ge-text-size').fill('30');
await page.locator('#ge-text-size').press('Enter');
const backgroundRow = page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first();
await editRow.click();
await page.locator('#ge-layer-tools .ge-layer-clip-btn').click();
await editRow.click();
await backgroundRow.click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
await expect(page.locator('.ge-layer-group-row')).toHaveCount(1);
await page.locator('#ge-select-all-layers').click();
const initial = await editorState(page);
expect(initial.selectedLayerIds).toHaveLength(3);
const bounds = selectionBounds(initial.layers);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await expect(page.locator('.ge-transform-popup .ge-adj-title')).toHaveText('Transform 3 layers');
expect(Number(await page.locator('#ge-transform-w').inputValue())).toBe(bounds.width);
expect(Number(await page.locator('#ge-transform-h').inputValue())).toBe(bounds.height);
await page.locator('#ge-transform-w').fill(String(bounds.width * 2));
expect(Number(await page.locator('#ge-transform-h').inputValue())).toBe(bounds.height * 2);
await page.locator('#ge-transform-apply').click();
const transformed = await editorState(page);
const initialByName = new Map(initial.layers.map(layer => [layer.name, layer]));
for (const layer of transformed.layers.filter(layer => layer.kind === 'raster')) {
const before = initialByName.get(layer.name);
expect(layer.size).toEqual([before.size[0] * 2, before.size[1] * 2]);
const expectedX = Math.round(bounds.centerX + (before.offset.x + before.size[0] / 2 - bounds.centerX) * 2 - layer.size[0] / 2);
const expectedY = Math.round(bounds.centerY + (before.offset.y + before.size[1] / 2 - bounds.centerY) * 2 - layer.size[1] / 2);
expect(layer.offset).toEqual({ x: expectedX, y: expectedY });
}
const transformedEdit = transformed.layers.find(layer => layer.name === 'Edit');
expect(transformedEdit.clipped).toBe(true);
expect(transformed.groups).toEqual(initial.groups);
expect(transformed.layers.map(layer => layer.id)).toEqual(initial.layers.map(layer => layer.id));
expect(transformedEdit.masks[0].size).toEqual(transformedEdit.size);
const beforeText = initial.layers.find(layer => layer.kind === 'text');
const afterText = transformed.layers.find(layer => layer.kind === 'text');
expect(afterText.text.content).toBe('Transform together');
expect(afterText.text.transform.scaleX).toBeCloseTo(beforeText.text.transform.scaleX * 2, 5);
expect(afterText.text.transform.scaleY).toBeCloseTo(beforeText.text.transform.scaleY * 2, 5);
await page.locator('#ge-undo').click();
const undone = await editorState(page);
expect(undone.layers).toEqual(initial.layers);
expect(undone.selectedLayerIds).toEqual(initial.selectedLayerIds);
await page.locator('#ge-redo').click();
expect((await editorState(page)).layers).toEqual(transformed.layers);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
const secondWidth = await page.locator('#ge-transform-w').inputValue();
await page.locator('#ge-transform-rot-90').click();
await expect(page.locator('#ge-transform-rot')).toHaveValue('90');
await expect.poll(async () => {
const edit = (await editorState(page)).layers.find(layer => layer.name === 'Edit');
return edit.size;
}).toEqual([transformedEdit.size[1], transformedEdit.size[0]]);
await page.locator('#ge-transform-flip-h').click();
await expect(page.locator('#ge-transform-w')).toHaveValue(`-${secondWidth}`);
await page.locator('#ge-transform-cancel-btn').click();
const cancelled = await editorState(page);
expect(cancelled.layers).toEqual(transformed.layers);
expect(cancelled.groups).toEqual(transformed.groups);
expect(cancelled.redo).toBe(0);
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await page.locator('#ge-transform-rot-90').click();
await expect(page.locator('#ge-transform-rot')).toHaveValue('90');
await page.locator('#ge-transform-flip-h').click();
await page.locator('#ge-transform-apply').click();
const finalTransform = await editorState(page);
const finalText = finalTransform.layers.find(layer => layer.kind === 'text');
expect(finalText.text.content).toBe('Transform together');
expect(finalText.text.transform.rotation).toBeCloseTo(afterText.text.transform.rotation + 90, 5);
expect(finalText.text.transform.flipH).toBe(!afterText.text.transform.flipH);
expect(finalTransform.groups).toEqual(transformed.groups);
expect(finalTransform.layers.find(layer => layer.name === 'Edit').clipped).toBe(true);
const downloadPromise = page.waitForEvent('download');
await page.locator('#ge-save-menu-btn').click();
await page.locator('#ge-save-project').click();
const download = await downloadPromise;
const projectBuffer = await fs.readFile(await download.path());
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers).toEqual(transformed.layers);
await page.locator('#ge-save-menu-btn').click();
const chooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-load-project').click();
const chooser = await chooserPromise;
await chooser.setFiles({
name: 'transform-roundtrip.geproj.json',
mimeType: 'application/json',
buffer: projectBuffer,
});
await expect.poll(async () => (await editorState(page)).layers).toEqual(finalTransform.layers);
expect((await editorState(page)).groups).toEqual(finalTransform.groups);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
expect((await editorState(page)).layers).toEqual(finalTransform.layers);
expect((await editorState(page)).groups).toEqual(finalTransform.groups);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,206 @@
const { test, expect } = require('@playwright/test');
const { editorState, flattenedPixelDigest, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
async function patternedPng(page, width, height, colors) {
const base64 = await page.evaluate(({ width: w, height: h, colors: palette }) => {
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const context = canvas.getContext('2d');
for (let y = 0; y < h; y += 1) for (let x = 0; x < w; x += 1) {
context.fillStyle = palette[(x + y * w) % palette.length];
context.fillRect(x, y, 1, 1);
}
return canvas.toDataURL('image/png').split(',')[1];
}, { width, height, colors });
return Buffer.from(base64, 'base64');
}
test('placed image transforms from source, replaces in place, rasterizes, and reopens', async ({ page, request }) => {
await openBlankEditor(page, { width: 300, height: 200 }, 'Placed layers E2E');
const firstImage = await patternedPng(page, 40, 20, ['#f44336', '#4caf50', '#2196f3', '#ffeb3b']);
const chooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-import-topbar').click();
const chooser = await chooserPromise;
await chooser.setFiles({ name: 'first-pattern.png', mimeType: 'image/png', buffer: firstImage });
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const imported = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
expect(imported.placed.sourceSize).toEqual([40, 20]);
const sourceHash = imported.placed.sourcePixelHash;
const row = page.locator(`.ge-layer-item[data-layer-id="${imported.id}"]`);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
const originalWidth = Number(await page.locator('#ge-transform-w').inputValue());
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth / 2)));
await page.locator('#ge-transform-apply').click();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await page.locator('#ge-transform-w').fill(String(originalWidth));
await page.locator('#ge-transform-apply').click();
const restoredSize = (await editorState(page)).layers.find(layer => layer.id === imported.id);
expect(restoredSize.placed.sourcePixelHash).toBe(sourceHash);
expect(restoredSize.size[0]).toBe(originalWidth);
expect(restoredSize.masks).toHaveLength(1);
const beforeReplaceFrame = { size: restoredSize.size, offset: restoredSize.offset };
const beforeReplaceMask = restoredSize.masks[0];
const replacement = await patternedPng(page, 20, 40, ['#111111', '#f8f8f8', '#ff00aa']);
const replaceChooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-layer-tools button[title="Replace placed image"]').click();
const replaceChooser = await replaceChooserPromise;
await replaceChooser.setFiles({ name: 'replacement.png', mimeType: 'image/png', buffer: replacement });
await expect.poll(async () => {
const layer = (await editorState(page)).layers.find(item => item.id === imported.id);
return layer.placed?.sourceName;
}).toBe('replacement.png');
const replaced = (await editorState(page)).layers.find(layer => layer.id === imported.id);
expect(replaced.size).toEqual(beforeReplaceFrame.size);
expect(replaced.offset).toEqual(beforeReplaceFrame.offset);
expect(replaced.placed.sourcePixelHash).not.toBe(sourceHash);
expect(replaced.masks[0]).toEqual(beforeReplaceMask);
const beforeRasterize = await flattenedPixelDigest(page);
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
const rasterized = (await editorState(page)).layers.find(layer => layer.id === imported.id);
expect(rasterized.kind).toBe('raster');
expect(rasterized.placed).toBeNull();
expect(await flattenedPixelDigest(page)).toEqual(beforeRasterize);
await page.locator('#ge-undo').click();
await expect.poll(async () => {
const layer = (await editorState(page)).layers.find(item => item.id === imported.id);
return layer.kind;
}).toBe('placed');
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = (await editorState(page)).layers.find(layer => layer.id === imported.id);
expect(reopened.kind).toBe('placed');
expect(reopened.placed.sourceName).toBe('replacement.png');
expect(reopened.masks[0]).toEqual(beforeReplaceMask);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('raster layers can be converted to editable sources without changing pixels', async ({ page, request }) => {
await openBlankEditor(page, { width: 180, height: 120 }, 'Editable source E2E');
const image = await patternedPng(page, 36, 24, ['#ef5350', '#42a5f5', '#66bb6a']);
const chooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-import-topbar').click();
const chooser = await chooserPromise;
await chooser.setFiles({ name: 'editable-source.png', mimeType: 'image/png', buffer: image });
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const placed = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
const raster = (await editorState(page)).layers.find(layer => layer.id === placed.id);
const before = { pixelHash: raster.pixelHash, size: raster.size, offset: raster.offset };
await page.locator('#ge-layer-tools button[title="Convert to editable source"]').click();
await expect.poll(async () => (await editorState(page)).layers.find(layer => layer.id === placed.id).kind).toBe('placed');
const converted = (await editorState(page)).layers.find(layer => layer.id === placed.id);
expect(converted.placed.sourceSize).toEqual(before.size);
expect(converted.placed.sourcePixelHash).toBe(before.pixelHash);
expect(converted.size).toEqual(before.size);
expect(converted.offset).toEqual(before.offset);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = (await editorState(page)).layers.find(layer => layer.id === placed.id);
expect(reopened.kind).toBe('placed');
expect(reopened.placed.sourcePixelHash).toBe(before.pixelHash);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('external clipboard images use the editable source import path', async ({ page, request }) => {
await openBlankEditor(page, { width: 180, height: 120 }, 'Clipboard source E2E');
const image = await patternedPng(page, 28, 18, ['#ef5350', '#42a5f5', '#66bb6a']);
await page.evaluate(base64 => {
const bytes = Uint8Array.from(atob(base64), char => char.charCodeAt(0));
const file = new File([bytes], 'clipboard.png', { type: 'image/png' });
const data = new DataTransfer();
data.items.add(file);
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true }));
}, image.toString('base64'));
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const pasted = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
expect(pasted.placed.sourceName).toBe('Pasted image');
expect(pasted.placed.sourceSize).toEqual([28, 18]);
const draftId = await waitForDraft(page);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('duplicating a placed layer copies its source independently', async ({ page, request }) => {
await openBlankEditor(page, { width: 180, height: 120 }, 'Duplicate source E2E');
const image = await patternedPng(page, 28, 18, ['#ef5350', '#42a5f5', '#66bb6a']);
const chooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-import-topbar').click();
await (await chooserPromise).setFiles({ name: 'duplicate-source.png', mimeType: 'image/png', buffer: image });
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const original = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
await page.locator(`.ge-layer-item[data-layer-id="${original.id}"]`).click();
await page.locator('#ge-layer-tools button[title="Duplicate layer"]').click();
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(2);
const sourceOwnership = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const placed = state.layers.filter(layer => layer.kind === 'placed');
return {
count: placed.length,
sameSource: placed[0].placed.sourceCanvas === placed[1].placed.sourceCanvas,
sizes: placed.map(layer => [layer.placed.sourceCanvas.width, layer.placed.sourceCanvas.height]),
};
});
expect(sourceOwnership).toEqual({ count: 2, sameSource: false, sizes: [[28, 18], [28, 18]] });
const draftId = await waitForDraft(page);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('raster transforms preserve source pixels by default and can opt out', async ({ page, request }) => {
await openBlankEditor(page, { width: 180, height: 120 }, 'Non-destructive transform E2E');
const image = await patternedPng(page, 42, 28, ['#ff7043', '#26a69a', '#5c6bc0']);
const chooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-import-topbar').click();
const chooser = await chooserPromise;
await chooser.setFiles({ name: 'transform-source.png', mimeType: 'image/png', buffer: image });
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const placed = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
const rasterBeforeTransform = (await editorState(page)).layers.find(layer => layer.id === placed.id);
const sourceHash = rasterBeforeTransform.pixelHash;
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await expect(page.locator('#ge-transform-preserve-source')).toBeChecked();
await page.locator('#ge-transform-w').fill('84');
await page.locator('#ge-transform-apply').click();
const preserved = (await editorState(page)).layers.find(layer => layer.id === placed.id);
expect(preserved.kind).toBe('placed');
expect(preserved.placed.sourceSize).toEqual([42, 28]);
expect(preserved.placed.sourcePixelHash).toBe(sourceHash);
const draftId = await waitForDraft(page);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('raster transforms can explicitly replace source pixels', async ({ page, request }) => {
await openBlankEditor(page, { width: 180, height: 120 }, 'Destructive transform E2E');
const image = await patternedPng(page, 42, 28, ['#ff7043', '#26a69a', '#5c6bc0']);
const chooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-import-topbar').click();
const chooser = await chooserPromise;
await chooser.setFiles({ name: 'destructive-transform.png', mimeType: 'image/png', buffer: image });
await expect.poll(async () => (await editorState(page)).layers.filter(layer => layer.kind === 'placed').length).toBe(1);
const placed = (await editorState(page)).layers.find(layer => layer.kind === 'placed');
await page.locator('#ge-layer-tools button[title="Rasterize placed layer"]').click();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await expect(page.locator('#ge-transform-preserve-source')).toBeChecked();
await page.locator('#ge-transform-preserve-source').uncheck();
await page.locator('#ge-transform-w').fill('60');
await page.locator('#ge-transform-apply').click();
const destructive = (await editorState(page)).layers.find(layer => layer.id === placed.id);
expect(destructive.kind).toBe('raster');
expect(destructive.placed).toBeNull();
const draftId = await waitForDraft(page);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
@@ -0,0 +1,180 @@
const { test, expect } = require('@playwright/test');
const { editorState, openBlankEditor } = require('./helpers.js');
async function currentPng(page) {
return page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.layers[0].canvas.toDataURL('image/png');
});
}
async function loadProject(page, project) {
await page.locator('#ge-save-menu-btn').click();
const chooserPromise = page.waitForEvent('filechooser');
await page.locator('#ge-load-project').click();
const chooser = await chooserPromise;
await chooser.setFiles({
name: 'recovery.geproj.json',
mimeType: 'application/json',
buffer: Buffer.from(JSON.stringify(project)),
});
}
test('mixed-corrupt project recovers valid layers and remains undoable', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Recovery E2E');
const before = await editorState(page);
const png = await currentPng(page);
await loadProject(page, {
type: 'odysseus-gallery-editor-project',
v: 5,
imgWidth: 160,
imgHeight: 120,
activeLayerId: 'broken',
nextLayerId: 3,
view: {},
layers: [
{ id: 'good', name: 'Recovered photo', canvasW: 160, canvasH: 120, dataUrl: png, offset: { x: 0, y: 0 }, masks: [] },
{ id: 'broken', name: 'Broken pixels', canvasW: 160, canvasH: 120, dataUrl: 'data:image/png;base64,AAAA', offset: { x: 0, y: 0 }, masks: [] },
],
});
await expect.poll(async () => (await editorState(page)).layers.map(layer => layer.name)).toEqual(['Recovered photo']);
await expect(page.locator('#toast')).toContainText('Broken pixels was skipped');
await page.locator('#ge-undo').click();
await expect.poll(async () => (await editorState(page)).layers.map(layer => layer.name)).toEqual(before.layers.map(layer => layer.name));
expect((await editorState(page)).dimensions).toEqual(before.dimensions);
});
test('fully corrupt project leaves the open document unchanged', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Recovery E2E');
const before = await editorState(page);
await loadProject(page, {
type: 'odysseus-gallery-editor-project',
v: 5,
imgWidth: 160,
imgHeight: 120,
activeLayerId: 'broken',
view: {},
layers: [
{ id: 'broken', name: 'Broken pixels', canvasW: 160, canvasH: 120, dataUrl: 'data:image/png;base64,AAAA', offset: { x: 0, y: 0 }, masks: [] },
],
});
await expect(page.locator('#toast')).toContainText('No recoverable layers could be decoded');
await expect.poll(async () => await editorState(page)).toEqual(before);
});
test('active editor reopens after a browser refresh', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Refresh recovery');
await expect.poll(async () => (await editorState(page)).draftId).not.toBeNull();
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page.locator('#gallery-modal')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('#gallery-editor-tab')).toHaveClass(/active/);
await expect(page.locator('.ge-main-canvas')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
await expect.poll(async () => (await editorState(page)).layers.map(layer => layer.name))
.toEqual(['Background', 'Edit']);
});
test('active editor reopens after a mobile browser refresh', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile refresh recovery');
await expect.poll(async () => (await editorState(page)).draftId).not.toBeNull();
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page.locator('#gallery-modal')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('#gallery-editor-tab')).toHaveClass(/active/);
await expect(page.locator('.ge-main-canvas')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
await expect.poll(async () => (await editorState(page)).layers.map(layer => layer.name))
.toEqual(['Background', 'Edit']);
});
test('new project size dialog cancels cleanly with Escape', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
await page.locator('#tool-gallery-btn').click();
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
await page.locator('#gallery-editor-tab').click();
await page.locator('#gallery-editor-new').click();
await expect(page.locator('#ge-canvas-size-overlay')).toBeVisible();
await page.locator('#ge-canvas-prompt-w').press('Escape');
await expect(page.locator('#ge-canvas-size-overlay')).toBeHidden();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.editorOpen;
})).toBe(false);
await expect(page.locator('#gallery-editor-new')).toBeVisible();
});
test('editor topbar uses uppercase action labels', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Topbar labels');
await expect(page.locator('#ge-view-menu-btn')).toHaveText(/VIEW/);
await expect(page.locator('#ge-image-menu-btn')).toHaveText(/IMAGE/);
await expect(page.locator('#ge-selection-menu-btn')).toHaveText(/SELECT/);
await expect(page.locator('#ge-filter-menu-btn')).toHaveText(/FILTER/);
await expect(page.locator('#ge-import-topbar')).toHaveText(/IMPORT/);
await expect(page.locator('#ge-save-menu-btn')).toHaveText(/SAVE/);
});
test('canvas size anchor keeps the composition centered', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Canvas anchor E2E');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.name === 'Edit');
state.layerOffsets.set(layer.id, { x: 10, y: 5 });
});
await page.locator('#ge-image-menu-btn').click();
await page.locator('[data-image-action="canvas-size"]').click();
await expect(page.locator('#ge-canvas-size-overlay')).toBeVisible();
await page.locator('#ge-canvas-prompt-lock').uncheck();
await page.locator('#ge-canvas-prompt-w').fill('420');
await page.locator('#ge-canvas-prompt-h').fill('340');
await page.locator('.ge-canvas-anchor').nth(4).click();
await page.locator('#ge-canvas-prompt-ok').click();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.name === 'Edit');
return { dimensions: [state.imgWidth, state.imgHeight], offset: state.layerOffsets.get(layer.id) };
})).toEqual({ dimensions: [420, 340], offset: { x: 60, y: 55 } });
});
test('image size supports percentage resampling with locked proportions', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Image size E2E');
await page.locator('#ge-image-menu-btn').click();
await page.locator('[data-image-action="image-size"]').click();
await expect(page.locator('#ge-canvas-size-overlay')).toBeVisible();
await expect(page.locator('#ge-canvas-prompt-units')).toHaveValue('px');
await page.locator('#ge-canvas-prompt-units').selectOption('percent');
await page.locator('#ge-canvas-prompt-w').fill('50');
await expect(page.locator('#ge-canvas-prompt-h')).toHaveValue('50');
await page.locator('#ge-canvas-prompt-interpolation').selectOption('medium');
await page.locator('#ge-canvas-prompt-ok').click();
await expect.poll(async () => (await editorState(page)).dimensions).toEqual([160, 120]);
await page.locator('#ge-image-menu-btn').click();
await page.locator('[data-image-action="image-size"]').click();
await expect(page.locator('#ge-canvas-prompt-units')).toHaveValue('px');
await expect(page.locator('#ge-canvas-prompt-w')).toHaveValue('160');
await expect(page.locator('#ge-canvas-prompt-h')).toHaveValue('120');
await page.locator('#ge-canvas-prompt-cancel').click();
});
test('canvas size supports percentage bounds with an anchor', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Canvas percentage E2E');
await page.locator('#ge-image-menu-btn').click();
await page.locator('[data-image-action="canvas-size"]').click();
await expect(page.locator('#ge-canvas-size-overlay')).toBeVisible();
await page.locator('#ge-canvas-prompt-units').selectOption('percent');
await page.locator('#ge-canvas-prompt-w').fill('125');
await expect(page.locator('#ge-canvas-prompt-h')).toHaveValue('125');
await page.locator('.ge-canvas-anchor').nth(4).click();
await page.locator('#ge-canvas-prompt-ok').click();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.name === 'Edit');
return { dimensions: [state.imgWidth, state.imgHeight], offset: state.layerOffsets.get(layer.id) };
})).toEqual({ dimensions: [400, 300], offset: { x: 40, y: 30 } });
});
@@ -0,0 +1,20 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, waitForDraft } = require('./helpers.js');
test('active editor draft reopens after a hard refresh', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 180 }, 'Refresh recovery E2E');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.2, y: 0.3 }, { x: 0.8, y: 0.7 });
const before = await editorState(page);
const draftId = await waitForDraft(page);
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page.locator('#gallery-editor-tab')).toBeVisible({ timeout: 20_000 });
await expect(page.locator('.gallery-editor')).toBeVisible({ timeout: 20_000 });
await expect.poll(async () => (await editorState(page)).draftId, { timeout: 20_000 }).toBe(draftId);
await expect.poll(async () => (await editorState(page)).documentRenderReady, { timeout: 20_000 }).toBe(true);
const after = await editorState(page);
expect(after.layers).toEqual(before.layers);
expect(after.dimensions).toEqual(before.dimensions);
});
@@ -0,0 +1,181 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers');
test('selection boundary animates, moves independently, and supports Quick Mask', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection workflow');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.55, y: 0.55 });
const initial = await editorState(page);
expect(initial.selection?.space).toBe('document');
expect(initial.selection?.bounds).toBeTruthy();
const layerBefore = initial.layers.find(layer => layer.id === initial.activeLayerId);
const overlayHash = () => page.locator('.ge-selection-overlay').evaluate(canvas => {
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
let hash = 0;
for (let i = 0; i < data.length; i += 4) if (data[i + 3]) hash = ((hash * 33) ^ data[i]) >>> 0;
return hash;
});
const firstOverlay = await overlayHash();
expect(firstOverlay).not.toBe(0);
await expect.poll(overlayHash, { timeout: 1200 }).not.toBe(firstOverlay);
await dragOnCanvas(page, { x: 0.35, y: 0.35 }, { x: 0.45, y: 0.42 });
const moved = await editorState(page);
expect(moved.selection.bounds.x).toBeGreaterThan(initial.selection.bounds.x);
expect(moved.selection.bounds.y).toBeGreaterThan(initial.selection.bounds.y);
const layerAfterMove = moved.layers.find(layer => layer.id === moved.activeLayerId);
expect(layerAfterMove.offset).toEqual(layerBefore.offset);
expect(layerAfterMove.pixelHash).toBe(layerBefore.pixelHash);
await page.keyboard.press('ArrowRight');
await expect.poll(async () => (await editorState(page)).selection.bounds.x).toBe(moved.selection.bounds.x + 1);
await page.keyboard.press('q');
await expect(page.locator('#ge-quick-mask-bar')).toBeVisible();
expect((await editorState(page)).quickMaskActive).toBe(true);
const beforePaint = (await editorState(page)).selection.pixelHash;
await dragOnCanvas(page, { x: 0.72, y: 0.72 }, { x: 0.78, y: 0.72 });
await expect.poll(async () => (await editorState(page)).selection.pixelHash).not.toBe(beforePaint);
await page.locator('.ge-quick-mask-done').click();
await expect(page.locator('#ge-quick-mask-bar')).toBeHidden();
expect((await editorState(page)).quickMaskActive).toBe(false);
});
test('marquee supports exact fixed-size and fixed-ratio geometry', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Precise marquee');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await page.locator('#ge-marquee-constraint').selectOption('size');
await page.locator('#ge-marquee-width').fill('80');
await page.locator('#ge-marquee-width').blur();
await page.locator('#ge-marquee-height').fill('60');
await page.locator('#ge-marquee-height').blur();
const box = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(box.x + box.width * 0.8, box.y + box.height * 0.75);
await expect.poll(async () => (await editorState(page)).selection?.bounds).toEqual({ x: 240, y: 180, width: 80, height: 60 });
await page.locator('#ge-marquee-clear').click();
await page.locator('#ge-marquee-constraint').selectOption('ratio');
await page.locator('#ge-marquee-width').fill('4');
await page.locator('#ge-marquee-width').blur();
await page.locator('#ge-marquee-height').fill('3');
await page.locator('#ge-marquee-height').blur();
await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.6, y: 0.3 });
const ratioBounds = (await editorState(page)).selection.bounds;
expect(ratioBounds.width).toBe(160);
expect(ratioBounds.height).toBe(120);
expect(ratioBounds.width / ratioBounds.height).toBeCloseTo(4 / 3, 5);
});
test('transform selection moves, scales, rotates, cancels, and preserves layer pixels', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Transform selection');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await dragOnCanvas(page, { x: 0.2, y: 0.25 }, { x: 0.5, y: 0.55 });
const original = await editorState(page);
const originalLayer = original.layers.find(layer => layer.id === original.activeLayerId);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="transform"]').click();
await expect(page.locator('.ge-transform-popup')).toBeVisible();
await expect(page.locator('.ge-transform-popup .ge-adj-title')).toHaveText('Transform Selection');
await dragOnCanvas(page, { x: 0.35, y: 0.4 }, { x: 0.45, y: 0.47 });
const moved = await editorState(page);
expect(moved.selection.bounds.x).toBeGreaterThan(original.selection.bounds.x);
expect(moved.selection.bounds.y).toBeGreaterThan(original.selection.bounds.y);
await page.locator('#ge-transform-w').fill('140');
await page.locator('#ge-transform-w').dispatchEvent('input');
await page.locator('#ge-transform-rot').fill('25');
await page.locator('#ge-transform-rot').dispatchEvent('input');
await page.locator('#ge-transform-apply').click();
let transformed = await editorState(page);
const transformedLayer = transformed.layers.find(layer => layer.id === transformed.activeLayerId);
expect(transformed.selection.pixelHash).not.toBe(original.selection.pixelHash);
expect(transformed.selection.bounds.width).toBeGreaterThan(original.selection.bounds.width);
expect(transformedLayer.offset).toEqual(originalLayer.offset);
expect(transformedLayer.pixelHash).toBe(originalLayer.pixelHash);
await page.keyboard.press('Control+z');
await expect.poll(async () => (await editorState(page)).selection.pixelHash).toBe(original.selection.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="transform"]').click();
await page.locator('#ge-transform-h').fill('40');
await page.locator('#ge-transform-h').dispatchEvent('input');
expect((await editorState(page)).selection.pixelHash).not.toBe(original.selection.pixelHash);
await page.locator('#ge-transform-cancel-btn').click();
transformed = await editorState(page);
expect(transformed.selection.pixelHash).toBe(original.selection.pixelHash);
expect(transformed.redo).toBe(0);
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(250);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="transform"]').click();
const mobilePopup = await page.locator('.ge-transform-popup').boundingBox();
const mobileTitle = await page.locator('.ge-transform-popup .ge-adj-title').boundingBox();
expect(mobilePopup.x).toBeGreaterThanOrEqual(0);
expect(mobilePopup.y).toBeGreaterThanOrEqual(0);
expect(mobilePopup.x + mobilePopup.width).toBeLessThanOrEqual(390);
expect(mobilePopup.y + mobilePopup.height).toBeLessThanOrEqual(844);
expect(mobileTitle.x).toBeGreaterThanOrEqual(mobilePopup.x);
expect(mobileTitle.x + mobileTitle.width).toBeLessThanOrEqual(mobilePopup.x + mobilePopup.width);
await page.locator('#ge-transform-cancel-btn').click();
});
test('named selections support reselect, load, delete, and server-draft reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Saved selections');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.5, y: 0.6 });
const original = (await editorState(page)).selection;
await page.locator('#ge-selection-menu-btn').click();
await page.locator('#ge-selection-name').fill('Subject');
await page.locator('[data-selection-action="save"]').click();
let current = await editorState(page);
expect(current.savedSelections).toHaveLength(1);
expect(current.savedSelections[0].name).toBe('Subject');
expect(current.savedSelections[0].pixelHash).toBe(original.pixelHash);
await page.locator('[data-selection-action="deselect"]').click();
current = await editorState(page);
expect(current.selection).toBeNull();
expect(current.lastSelection.pixelHash).toBe(original.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="reselect"]').click();
expect((await editorState(page)).selection.pixelHash).toBe(original.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="deselect"]').click();
await dragOnCanvas(page, { x: 0.62, y: 0.15 }, { x: 0.88, y: 0.4 });
expect((await editorState(page)).selection.pixelHash).not.toBe(original.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('.ge-saved-selection-load', { hasText: 'Subject' }).click();
expect((await editorState(page)).selection.pixelHash).toBe(original.pixelHash);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.savedSelections).toHaveLength(1);
expect(current.savedSelections[0].pixelHash).toBe(original.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('.ge-saved-selection-load', { hasText: 'Subject' }).click();
expect((await editorState(page)).selection.pixelHash).toBe(original.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('.ge-saved-selection-delete').click();
expect((await editorState(page)).savedSelections).toHaveLength(0);
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator('#ge-selection-menu')).toBeHidden();
await page.waitForTimeout(250);
await page.locator('#ge-selection-menu-btn').click();
await expect(page.locator('#ge-selection-menu')).toBeVisible();
const mobileMenu = await page.locator('#ge-selection-menu').boundingBox();
expect(mobileMenu.x).toBeGreaterThanOrEqual(0);
expect(mobileMenu.x + mobileMenu.width).toBeLessThanOrEqual(390);
});
@@ -0,0 +1,171 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers');
async function openRefine(page) {
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="refine"]').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
}
async function drawLasso(page, points) {
const box = await page.locator('.ge-main-canvas').boundingBox();
const point = ([x, y]) => ({ x: box.x + box.width * x, y: box.y + box.height * y });
const first = point(points[0]);
await page.mouse.move(first.x, first.y);
await page.mouse.down();
for (const item of points.slice(1)) {
const next = point(item);
await page.mouse.move(next.x, next.y, { steps: 3 });
}
await page.mouse.move(first.x, first.y, { steps: 3 });
await page.mouse.up();
}
test('selection clipboard copy and paste creates an undoable independent layer', async ({ page, request }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection clipboard');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.18, y: 0.24 }, { x: 0.72, y: 0.68 });
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.65, y: 0.65 });
const before = await editorState(page);
await page.keyboard.press('Control+c');
await page.evaluate(() => {
window.dispatchEvent(new Event('paste', { bubbles: true, cancelable: true }));
});
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Pasted Selection' })).toBeVisible();
const pasted = await editorState(page);
expect(pasted.layers).toHaveLength(before.layers.length + 1);
const pastedLayer = pasted.layers.at(-1);
expect(pasted.activeLayerId).toBe(pastedLayer.id);
expect(pastedLayer.kind).toBe('placed');
expect(pastedLayer.placed.sourceSize).toEqual(pastedLayer.size);
await page.locator('#ge-undo').click();
await expect.poll(async () => (await editorState(page)).layers.length).toBe(before.layers.length);
await page.locator('#ge-redo').click();
await expect.poll(async () => (await editorState(page)).layers.length).toBe(before.layers.length + 1);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
const reopenedLayer = reopened.layers.at(-1);
expect(reopenedLayer.kind).toBe('placed');
expect(reopenedLayer.placed.sourceSize).toEqual(pastedLayer.size);
await request.delete(`/api/editor-drafts/${encodeURIComponent(draftId)}`);
});
test('cut selection is one undoable move from source to new layer', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection cut');
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.18, y: 0.24 }, { x: 0.72, y: 0.68 });
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.65, y: 0.65 });
const before = await editorState(page);
const sourceSignature = () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.name === 'Edit');
const data = layer.canvas.getContext('2d').getImageData(0, 0, layer.canvas.width, layer.canvas.height).data;
let hash = 2166136261;
for (const value of data) {
hash ^= value;
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
});
const beforeSource = await sourceSignature();
await page.keyboard.press('Control+x');
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Wand copy' })).toBeVisible();
const cut = await editorState(page);
expect(cut.layers).toHaveLength(before.layers.length + 1);
expect(await sourceSignature()).not.toBe(beforeSource);
await page.locator('#ge-undo').click();
await expect.poll(async () => (await editorState(page)).layers.length).toBe(before.layers.length);
expect(await sourceSignature()).toBe(beforeSource);
});
test('completed lasso copy preserves the source layer coordinate space', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Offset selection copy');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.layerOffsets.set(state.activeLayerId, { x: 32, y: 24 });
window.galleryEditorComposite?.();
});
await page.locator('.ge-tool-btn[data-tool="brush"]').click();
await dragOnCanvas(page, { x: 0.28, y: 0.3 }, { x: 0.62, y: 0.58 });
await page.locator('.ge-tool-btn[data-tool="lasso"]').click();
await drawLasso(page, [[0.26, 0.26], [0.68, 0.26], [0.68, 0.64], [0.26, 0.64]]);
const before = await editorState(page);
const source = before.layers.find(layer => layer.id === before.activeLayerId);
await page.locator('#ge-lasso-copy').click();
await expect(page.locator('.ge-layer-item').filter({ hasText: 'Wand copy' })).toBeVisible();
const copied = await editorState(page);
const copy = copied.layers.find(layer => layer.name === 'Wand copy');
expect(copy.size).toEqual(source.size);
expect(copy.offset).toEqual(source.offset);
});
test('selection refine previews safely and layer masks round-trip to selection', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Selection refinement');
await page.locator('.ge-tool-btn[data-tool="marquee"]').click();
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.55, y: 0.55 });
const original = (await editorState(page)).selection;
await openRefine(page);
const expand = page.locator('.ge-filter-modal input[data-key="expand"]');
await expand.fill('12');
await expand.dispatchEvent('input');
await expect.poll(async () => (await editorState(page)).selection.bounds.width)
.toBeGreaterThan(original.bounds.width);
await page.locator('.ge-filter-modal [data-action="cancel"]').click();
expect((await editorState(page)).selection.pixelHash).toBe(original.pixelHash);
await openRefine(page);
const appliedExpand = page.locator('.ge-filter-modal input[data-key="expand"]');
await appliedExpand.fill('12');
await appliedExpand.dispatchEvent('input');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
const refined = (await editorState(page)).selection;
expect(refined.bounds.width).toBeGreaterThan(original.bounds.width);
expect(refined.pixelHash).not.toBe(original.pixelHash);
await page.locator('#ge-layer-tools .ge-true-mask-btn').click();
let current = await editorState(page);
const active = current.layers.find(layer => layer.id === current.activeLayerId);
expect(active.masks).toHaveLength(1);
expect(active.masks[0].mode).toBe('layer');
expect(active.masks[0].pixelHash).toBe(refined.pixelHash);
await page.locator('#ge-selection-menu-btn').click();
await page.locator('[data-selection-action="deselect"]').click();
expect((await editorState(page)).selection).toBeNull();
await page.getByRole('button', { name: 'Load mask as selection' }).click();
current = await editorState(page);
expect(current.selection.pixelHash).toBe(refined.pixelHash);
});
test('lasso uses the shared replace, add, subtract, and intersect modes', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Lasso combine modes');
await page.locator('.ge-tool-btn[data-tool="lasso"]').click();
await drawLasso(page, [[0.1, 0.2], [0.35, 0.2], [0.35, 0.55], [0.1, 0.55]]);
const first = (await editorState(page)).selection;
expect(first.source).toBe('lasso');
await page.locator('#ge-lasso-section [data-wand-mode="add"]').click();
await drawLasso(page, [[0.55, 0.2], [0.85, 0.2], [0.85, 0.55], [0.55, 0.55]]);
const added = (await editorState(page)).selection;
expect(added.bounds.width).toBeGreaterThan(first.bounds.width);
await page.locator('#ge-lasso-section [data-wand-mode="subtract"]').click();
await drawLasso(page, [[0.05, 0.15], [0.4, 0.15], [0.4, 0.6], [0.05, 0.6]]);
const subtracted = (await editorState(page)).selection;
expect(subtracted.bounds.x).toBeGreaterThan(first.bounds.x);
await page.locator('#ge-lasso-section [data-wand-mode="intersect"]').click();
await drawLasso(page, [[0.65, 0.25], [0.78, 0.25], [0.78, 0.48], [0.65, 0.48]]);
const intersected = (await editorState(page)).selection;
expect(intersected.bounds.width).toBeLessThan(subtracted.bounds.width);
});
+334
View File
@@ -0,0 +1,334 @@
const { test, expect } = require('@playwright/test');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
test('text edits directly on canvas and remains retained after reopen', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="text"]').click();
await page.locator('#ge-text-frame-width').fill('260');
const canvas = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(canvas.x + canvas.width * 0.2, canvas.y + canvas.height * 0.2);
const editor = page.locator('.ge-direct-text-editor');
await expect(editor).toBeVisible();
await editor.fill('Editable canvas title');
await editor.press('Control+Enter');
await expect(editor).toHaveCount(0);
await page.locator('#ge-text-letter-spacing').fill('3.5');
await page.locator('#ge-text-line-height').fill('1.4');
await page.locator('#ge-text-font').selectOption('Georgia');
await page.locator('.ge-text-auto-width-option').click();
await expect(page.locator('#ge-text-auto-width')).toBeChecked();
await expect(page.locator('#ge-text-frame-width')).toBeDisabled();
await page.locator('#ge-text-frame-height').fill('180');
await page.locator('#ge-text-vertical-align').selectOption('bottom');
const before = await editorState(page);
const textLayer = before.layers.find(layer => layer.kind === 'text');
expect(textLayer.text).toMatchObject({
content: 'Editable canvas title',
fontFamily: 'Georgia',
lineHeight: 1.4,
letterSpacing: 3.5,
autoWidth: true,
frameHeight: 180,
verticalAlign: 'bottom',
});
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(layer => layer.kind === 'text').text).toEqual(textLayer.text);
});
test('text paragraph controls remain usable in a narrow phone viewport', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 700 });
await openBlankEditor(page, { width: 240, height: 180 }, 'Mobile text layout E2E');
await page.locator('.ge-tool-btn[data-tool="text"]').click();
const section = page.locator('#ge-text-section');
await expect(section).toBeVisible();
expect(await section.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
await expect(page.locator('#ge-text-frame-height')).toBeVisible();
await expect(page.locator('#ge-text-vertical-align')).toBeVisible();
});
test('rectangle ellipse line and polygon remain editable shape layers', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
await expect(page.locator('#ge-shape-section')).toBeVisible();
const types = ['rectangle', 'ellipse', 'line', 'polygon'];
for (let index = 0; index < types.length; index += 1) {
const type = types[index];
await page.locator('.ge-layer-item').filter({ hasText: 'Edit' }).first().click();
await page.locator(`[data-shape-type="${type}"]`).click();
await dragOnCanvas(
page,
{ x: 0.12 + index * 0.18, y: 0.2 },
{ x: 0.25 + index * 0.18, y: 0.42 },
);
}
let current = await editorState(page);
const shapes = current.layers.filter(layer => layer.kind === 'shape');
expect(shapes.map(layer => layer.shape.type)).toEqual(types);
await page.locator('#ge-shape-sides').fill('7');
await page.locator('#ge-shape-stroke-width').fill('6');
await page.locator('#ge-shape-radius').fill('14');
current = await editorState(page);
const polygon = current.layers.find(layer => layer.kind === 'shape' && layer.shape.type === 'polygon');
expect(polygon.shape).toMatchObject({ sides: 7, strokeWidth: 6, cornerRadius: 14 });
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
const originalWidth = Number(await page.locator('#ge-transform-w').inputValue());
await page.locator('#ge-transform-w').fill(String(originalWidth + 40));
await page.locator('#ge-transform-apply').click();
current = await editorState(page);
const transformed = current.layers.find(layer => layer.id === polygon.id);
expect(transformed.kind).toBe('shape');
expect(transformed.shape.transform.scaleX).toBeGreaterThan(1);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.layers.filter(layer => layer.kind === 'shape').map(layer => layer.shape.type)).toEqual(types);
});
test('gradient shape fill remains editable after reopening the project', async ({ page }) => {
await openBlankEditor(page);
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeHidden();
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.65, y: 0.55 });
await page.locator('#ge-shape-gradient-angle').evaluate((angle) => {
const section = angle.closest('#ge-shape-section');
section.querySelector('#ge-shape-fill-type').value = 'linear-gradient';
section.querySelector('#ge-shape-gradient-start').value = '#ff0000';
section.querySelector('#ge-shape-gradient-mid').value = '#00ff00';
section.querySelector('#ge-shape-gradient-mid-enabled').checked = true;
section.querySelector('#ge-shape-gradient-mid-position').value = '42';
section.querySelector('#ge-shape-gradient-end').value = '#0000ff';
section.querySelector('#ge-shape-fill-type').dispatchEvent(new Event('change', { bubbles: true }));
angle.value = '35';
angle.dispatchEvent(new Event('input', { bubbles: true }));
});
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeVisible();
const before = await editorState(page);
const shape = before.layers.find(layer => layer.kind === 'shape');
expect(shape.shape).toMatchObject({
fillType: 'linear-gradient',
gradientStart: '#ff0000',
gradientMid: '#00ff00',
gradientMidEnabled: true,
gradientMidPosition: 42,
gradientEnd: '#0000ff',
gradientAngle: 35,
});
expect(page.locator('.ge-layer-inline-thumb')).toHaveCount(before.layers.length);
const thumbnailColorRange = await page.locator('.ge-layer-inline-thumb').evaluateAll((canvases) => canvases.map(canvas => {
const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
return {
red: Math.max(...Array.from(pixels).filter((_, index) => index % 4 === 0)),
blue: Math.max(...Array.from(pixels).filter((_, index) => index % 4 === 2)),
};
}));
expect(thumbnailColorRange.some(({ red, blue }) => red > 180 && blue > 180)).toBe(true);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(layer => layer.id === shape.id).shape).toMatchObject(shape.shape);
});
test('shape gradients retain added stops through editing and reopen', async ({ page }) => {
await openBlankEditor(page, { width: 360, height: 240 }, 'Multi-stop gradient E2E');
await page.locator('.ge-tool-btn[data-tool="shape"]').click();
await dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.75, y: 0.65 });
await page.locator('#ge-shape-fill-type').selectOption('linear-gradient');
await page.evaluate(() => {
for (const [id, value] of [['ge-shape-gradient-start', '#ff0000'], ['ge-shape-gradient-end', '#0000ff']]) {
const input = document.getElementById(id);
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
});
await page.locator('#ge-shape-gradient-add-stop').click();
await expect(page.locator('[data-gradient-extra-stop]')).toHaveCount(1);
await page.evaluate(() => {
const color = document.querySelector('[data-gradient-stop-color]');
color.value = '#00ff00';
color.dispatchEvent(new Event('input', { bubbles: true }));
const position = document.querySelector('[data-gradient-stop-position]');
position.value = '50';
position.dispatchEvent(new Event('input', { bubbles: true }));
});
await page.locator('#ge-shape-gradient-add-stop').click();
await expect(page.locator('[data-gradient-extra-stop]')).toHaveCount(2);
await page.evaluate(() => {
const rows = [...document.querySelectorAll('[data-gradient-extra-stop]')];
const setRow = (row, colorValue, positionValue) => {
const color = row.querySelector('[data-gradient-stop-color]');
color.value = colorValue;
color.dispatchEvent(new Event('input', { bubbles: true }));
const position = row.querySelector('[data-gradient-stop-position]');
position.value = positionValue;
position.dispatchEvent(new Event('input', { bubbles: true }));
};
setRow(rows[0], '#00ff00', '50');
setRow(rows[1], '#ffff00', '75');
});
let current = await editorState(page);
const shape = current.layers.find(layer => layer.kind === 'shape');
expect(shape.shape.gradientStops).toEqual([
{ position: 0, color: '#ff0000' },
{ position: 50, color: '#00ff00' },
{ position: 75, color: '#ffff00' },
{ position: 100, color: '#0000ff' },
]);
await page.locator('[data-gradient-extra-stop]').first().locator('[data-gradient-stop-remove]').click();
current = await editorState(page);
expect(current.layers.find(layer => layer.id === shape.id).shape.gradientStops).toEqual([
{ position: 0, color: '#ff0000' },
{ position: 75, color: '#ffff00' },
{ position: 100, color: '#0000ff' },
]);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
expect(reopened.layers.find(layer => layer.id === shape.id).shape.gradientStops).toEqual([
{ position: 0, color: '#ff0000' },
{ position: 75, color: '#ffff00' },
{ position: 100, color: '#0000ff' },
]);
});
test('shape gradient stop normalization keeps imported endpoints bounded', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 180 }, 'Gradient stop bounds E2E');
const result = await page.evaluate(async () => {
const { MAX_GRADIENT_STOPS, normalizeGradientStops } = await import('/static/js/editor/gradient-stops.js');
const stops = normalizeGradientStops(Array.from({ length: 30 }, (_, index) => ({
position: index * 3,
color: `#${String(index).padStart(6, '0')}`,
})));
return { max: MAX_GRADIENT_STOPS, stops };
});
expect(result.stops).toHaveLength(result.max);
expect(result.stops[0].position).toBe(0);
expect(result.stops.at(-1).position).toBe(100);
});
test('gradient tool paints a reversible drag on the active layer', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Gradient tool E2E');
await page.locator('.ge-tool-btn[data-tool="gradient"]').click();
await expect(page.locator('#ge-gradient-section')).toBeVisible();
await page.evaluate(() => {
for (const [id, value] of [['ge-gradient-start', '#ff0000'], ['ge-gradient-end', '#0000ff']]) {
const input = document.getElementById(id);
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
const midpoint = document.getElementById('ge-gradient-mid');
midpoint.value = '#00ff00';
midpoint.dispatchEvent(new Event('input', { bubbles: true }));
const enabled = document.getElementById('ge-gradient-mid-enabled');
enabled.checked = true;
enabled.dispatchEvent(new Event('change', { bubbles: true }));
});
await page.locator('#ge-gradient-add-stop').click();
await page.evaluate(() => {
const row = document.querySelector('#ge-gradient-extra-stops [data-gradient-extra-stop]');
const color = row.querySelector('[data-gradient-stop-color]');
color.value = '#ffff00';
color.dispatchEvent(new Event('input', { bubbles: true }));
const position = row.querySelector('[data-gradient-stop-position]');
position.value = '25';
position.dispatchEvent(new Event('input', { bubbles: true }));
});
const box = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.move(box.x + 8, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width - 8, box.y + box.height / 2, { steps: 8 });
await page.mouse.up();
await expect.poll(async () => (await editorState(page)).layers.at(-1).effects?.length || 0).toBe(1);
await expect.poll(async () => (await editorState(page)).documentRenderReady).toBe(true);
const samples = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
const rendered = state.documentCompositeCanvas;
const ctx = rendered.getContext('2d');
const y = Math.floor(rendered.height / 2);
return [
ctx.getImageData(4, y, 1, 1).data,
ctx.getImageData(Math.floor(rendered.width / 2), y, 1, 1).data,
ctx.getImageData(rendered.width - 5, y, 1, 1).data,
];
});
expect(samples[0][0]).toBeGreaterThan(220);
expect(samples[0][2]).toBeLessThan(40);
expect(samples[1][1]).toBeGreaterThan(180);
expect(samples[1][0]).toBeLessThan(80);
expect(samples[1][2]).toBeLessThan(80);
expect(samples[2][2]).toBeGreaterThan(220);
expect(samples[2][0]).toBeLessThan(40);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
const reopened = await editorState(page);
const reopenedGradient = reopened.layers.find(layer => layer.effects?.some(effect => effect.type === 'linear-gradient'));
expect(reopenedGradient).toBeTruthy();
expect(reopenedGradient.effects.find(effect => effect.type === 'linear-gradient').params.stops).toEqual([
{ position: 0, color: '#ff0000', alpha: 1 },
{ position: 25, color: '#ffff00', alpha: 1 },
{ position: 50, color: '#00ff00', alpha: 1 },
{ position: 100, color: '#0000ff', alpha: 1 },
]);
await page.locator('.ge-effect-sub-item .ge-adj-sub-name').click();
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await expect(page.locator('.ge-filter-row input[data-key="stopColor0"]')).toHaveValue('#ffff00');
await expect(page.locator('.ge-filter-row input[data-key="stopPosition0"]')).toHaveValue('25');
await expect(page.locator('.ge-filter-row input[data-key="stopColor1"]')).toHaveValue('#00ff00');
await page.locator('.ge-filter-row input[data-key="stopColor0"]').fill('#ffff00');
await page.locator('.ge-filter-row input[data-key="stopPosition0"]').fill('30');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
const edited = (await editorState(page)).layers
.flatMap(layer => layer.effects || [])
.find(effect => effect.type === 'linear-gradient');
expect(edited.params.stops).toEqual([
{ position: 0, color: '#ff0000', alpha: 1 },
{ position: 30, color: '#ffff00', alpha: 1 },
{ position: 50, color: '#00ff00', alpha: 1 },
{ position: 100, color: '#0000ff', alpha: 1 },
]);
});
test('radial gradient remains retained and survives reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Radial gradient E2E');
await page.locator('.ge-tool-btn[data-tool="gradient"]').click();
await page.locator('#ge-gradient-type').selectOption('radial-gradient');
await page.evaluate(() => {
const start = document.getElementById('ge-gradient-start');
start.value = '#ffffff';
start.dispatchEvent(new Event('input', { bubbles: true }));
const end = document.getElementById('ge-gradient-end');
end.value = '#000000';
end.dispatchEvent(new Event('input', { bubbles: true }));
});
await dragOnCanvas(page, { x: 0.5, y: 0.5 }, { x: 0.85, y: 0.5 });
let current = await editorState(page);
const gradient = current.layers.flatMap(layer => layer.effects || []).find(effect => effect.type === 'radial-gradient');
expect(gradient).toBeTruthy();
expect(gradient.params.stops).toEqual([
{ position: 0, color: '#ffffff', alpha: 1 },
{ position: 100, color: '#000000', alpha: 1 },
]);
const draftId = await waitForDraft(page);
await reopenDraft(page, draftId);
current = await editorState(page);
expect(current.layers.flatMap(layer => layer.effects || []).some(effect => effect.type === 'radial-gradient')).toBe(true);
});
@@ -0,0 +1,320 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor } = require('./helpers');
test('transform frame exposes eight accurate handles and supports edge resize', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Transform frame');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await expect(page.locator('.ge-transform-popup')).toBeVisible();
const frame = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js');
const { getHandleAt } = await import('/static/js/editor/tools/transform-handles.js');
const geometry = transformFrameGeometry({
centerX: state.transformCenter.x,
centerY: state.transformCenter.y,
width: state.transformPendingW,
height: state.transformPendingH,
rotation: state.transformPendingRot,
}, { zoom: state.zoom });
const canvasRect = state.mainCanvas.getBoundingClientRect();
const right = geometry.resizeHandles.find(handle => handle.id === 'r');
return {
width: state.transformPendingW,
ids: geometry.resizeHandles.map(handle => getHandleAt(handle.x, handle.y)),
rightClient: {
x: canvasRect.left + right.x * canvasRect.width / state.mainCanvas.width,
y: canvasRect.top + right.y * canvasRect.height / state.mainCanvas.height,
},
};
});
expect(frame.ids).toEqual(['tl', 't', 'tr', 'r', 'br', 'b', 'bl', 'l']);
await page.mouse.move(frame.rightClient.x, frame.rightClient.y);
await page.mouse.down();
await page.mouse.move(frame.rightClient.x + 36, frame.rightClient.y, { steps: 6 });
await page.mouse.up();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformPendingW;
})).toBeGreaterThan(frame.width);
});
test('rotated edge resize follows the frame axis and keeps its opposite edge anchored', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Rotated transform frame');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await page.locator('#ge-transform-rot').fill('90');
const frame = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js');
const geometry = transformFrameGeometry({
centerX: state.transformCenter.x,
centerY: state.transformCenter.y,
width: state.transformPendingW,
height: state.transformPendingH,
rotation: state.transformPendingRot,
}, { zoom: state.zoom });
const right = geometry.resizeHandles.find(handle => handle.id === 'r');
const canvasRect = state.mainCanvas.getBoundingClientRect();
return {
width: state.transformPendingW,
center: { ...state.transformCenter },
rightClient: {
x: canvasRect.left + right.x * canvasRect.width / state.mainCanvas.width,
y: canvasRect.top + right.y * canvasRect.height / state.mainCanvas.height,
},
};
});
await page.mouse.move(frame.rightClient.x, frame.rightClient.y);
await page.mouse.down();
await page.mouse.move(frame.rightClient.x, frame.rightClient.y + 36, { steps: 6 });
await page.mouse.up();
const after = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { width: state.transformPendingW, center: { ...state.transformCenter } };
});
expect(after.width).toBeGreaterThan(frame.width);
expect(after.center.x).toBeCloseTo(frame.center.x, 4);
expect(after.center.y).toBeGreaterThan(frame.center.y);
});
test('rotated frame moves only from its visible interior and supports keyboard nudging', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Transform interaction');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await page.locator('#ge-transform-rot').fill('45');
const frame = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
state.snapEnabled = false;
const rect = state.mainCanvas.getBoundingClientRect();
const toClient = point => ({
x: rect.left + point.x * rect.width / state.mainCanvas.width,
y: rect.top + point.y * rect.height / state.mainCanvas.height,
});
return {
center: { ...state.transformCenter },
emptyCorner: toClient({ x: 5, y: 5 }),
centerClient: toClient(state.transformCenter),
};
});
await page.mouse.move(frame.emptyCorner.x, frame.emptyCorner.y);
await page.mouse.down();
await page.mouse.move(frame.emptyCorner.x + 24, frame.emptyCorner.y + 18);
await page.mouse.up();
const afterEmptyCorner = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { ...state.transformCenter };
});
expect(afterEmptyCorner.x).toBeCloseTo(frame.center.x, 5);
expect(afterEmptyCorner.y).toBeCloseTo(frame.center.y, 5);
await page.mouse.move(frame.centerClient.x, frame.centerClient.y);
await page.mouse.down();
await page.mouse.move(frame.centerClient.x + 24, frame.centerClient.y + 18, { steps: 4 });
await page.mouse.up();
const afterDrag = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { ...state.transformCenter };
});
expect(afterDrag.x).toBeGreaterThan(frame.center.x);
expect(afterDrag.y).toBeGreaterThan(frame.center.y);
await page.keyboard.press('ArrowRight');
await page.keyboard.press('Shift+ArrowDown');
const afterKeys = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { ...state.transformCenter };
});
expect(afterKeys.x).toBeCloseTo(afterDrag.x + 1, 5);
expect(afterKeys.y).toBeCloseTo(afterDrag.y + 10, 5);
const readout = {
x: Number(await page.locator('#ge-transform-x').inputValue()),
y: Number(await page.locator('#ge-transform-y').inputValue()),
w: Number(await page.locator('#ge-transform-w').inputValue()),
h: Number(await page.locator('#ge-transform-h').inputValue()),
angle: Number(await page.locator('#ge-transform-rot').inputValue()),
};
expect(readout.x).toBeCloseTo(afterKeys.x, 2);
expect(readout.y).toBeCloseTo(afterKeys.y, 2);
expect(readout.w).toBeGreaterThan(0);
expect(readout.h).toBeGreaterThan(0);
expect(readout.angle).toBe(45);
await page.locator('#ge-transform-x').fill('250');
await page.locator('#ge-transform-y').fill('180');
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return { ...state.transformCenter };
})).toEqual({ x: 250, y: 180 });
await page.keyboard.press('Escape');
await expect(page.locator('.ge-transform-popup')).toBeHidden();
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformActive;
})).toBe(false);
});
test('transform previews remain source-derived and reject unsafe allocations', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 160 }, 'Transform source integrity');
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
for (let y = 0; y < layer.canvas.height; y += 8) {
for (let x = 0; x < layer.canvas.width; x += 8) {
layer.ctx.fillStyle = ((x / 8 + y / 8) % 2) ? '#f24f5f' : '#27c2a3';
layer.ctx.fillRect(x, y, 8, 8);
}
}
});
const layerSnapshot = () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
const pixels = layer.ctx.getImageData(0, 0, layer.canvas.width, layer.canvas.height).data;
let hash = 2166136261;
for (const value of pixels) hash = Math.imul(hash ^ value, 16777619);
return { width: layer.canvas.width, height: layer.canvas.height, hash: hash >>> 0 };
});
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
const originalWidth = Number(await page.locator('#ge-transform-w').inputValue());
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.8)));
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.35)));
const afterSequentialPreviews = await layerSnapshot();
await page.locator('#ge-transform-cancel-btn').click();
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await page.locator('#ge-transform-w').fill(String(Math.round(originalWidth * 1.35)));
const afterDirectPreview = await layerSnapshot();
expect(afterSequentialPreviews).toEqual(afterDirectPreview);
const safeWidth = await page.locator('#ge-transform-w').inputValue();
const beforeRejected = await layerSnapshot();
await page.locator('#ge-transform-w').fill('40000');
await expect(page.locator('#toast')).toContainText('dimension limit');
await expect(page.locator('#ge-transform-w')).toHaveValue(safeWidth);
expect(await layerSnapshot()).toEqual(beforeRejected);
await page.locator('#ge-transform-cancel-btn').click();
});
test('mobile touch input can grab every transform handle', async ({ browser, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP touch injection');
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
serviceWorkers: 'block',
});
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile transform handles');
await page.locator('.ge-tool-btn[data-tool="transform"]').click();
await expect(page.locator('.ge-transform-popup')).toBeVisible();
const cdp = await context.newCDPSession(page);
const ids = ['tl', 't', 'tr', 'r', 'br', 'b', 'bl', 'l', 'rot'];
const handlePoint = id => page.evaluate(async handleId => {
const { state } = await import('/static/js/editor/state.js');
const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js');
const geometry = transformFrameGeometry({
centerX: state.transformCenter.x,
centerY: state.transformCenter.y,
width: state.transformPendingW,
height: state.transformPendingH,
rotation: state.transformPendingRot,
}, { zoom: state.zoom, rotationInside: handleId === 'rot' });
const handle = geometry.handles.find(item => item.id === handleId);
const rect = state.mainCanvas.getBoundingClientRect();
return {
x: rect.left + handle.x * rect.width / state.mainCanvas.width,
y: rect.top + handle.y * rect.height / state.mainCanvas.height,
};
}, id);
for (let index = 0; index < ids.length; index += 1) {
const expectedId = ids[index];
const point = await handlePoint(expectedId);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: point.x, y: point.y, id: index + 1, radiusX: 4, radiusY: 4 }],
});
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformHandle;
})).toBe(expectedId);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformHandle;
})).toBe(null);
}
const right = await handlePoint('r');
const widthBefore = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformPendingW;
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: right.x, y: right.y, id: 20, radiusX: 4, radiusY: 4 }],
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: right.x + 80, y: right.y, id: 20, radiusX: 4, radiusY: 4 }],
});
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformPendingW;
})).toBeGreaterThan(widthBefore);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
const center = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const rect = state.mainCanvas.getBoundingClientRect();
return {
x: rect.left + state.transformCenter.x * rect.width / state.mainCanvas.width,
y: rect.top + state.transformCenter.y * rect.height / state.mainCanvas.height,
zoom: state.zoom,
};
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: center.x, y: center.y, id: 30, radiusX: 4, radiusY: 4 }],
});
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformHandle;
})).toBe('move');
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [
{ x: center.x - 20, y: center.y, id: 30, radiusX: 4, radiusY: 4 },
{ x: center.x + 20, y: center.y, id: 31, radiusX: 4, radiusY: 4 },
],
});
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.transformHandle;
})).toBe(null);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [
{ x: center.x - 45, y: center.y, id: 30, radiusX: 4, radiusY: 4 },
{ x: center.x + 45, y: center.y, id: 31, radiusX: 4, radiusY: 4 },
],
});
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.zoom;
})).toBeGreaterThan(center.zoom);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
} finally {
await context.close();
}
});
+60
View File
@@ -0,0 +1,60 @@
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { defineConfig } = require('@playwright/test');
const port = Number(process.env.PHOTO_EDITOR_E2E_PORT || 7013);
const baseURL = `http://127.0.0.1:${port}`;
const repositoryRoot = path.join(__dirname, '..', '..');
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 : 'python');
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR
|| fs.mkdtempSync(path.join(os.tmpdir(), 'odysseus-photo-editor-e2e-'));
const databasePath = process.env.PHOTO_EDITOR_E2E_DB_PATH
|| path.join(dataDirectory, 'app.db');
process.env.PHOTO_EDITOR_E2E_DB_PATH = databasePath;
process.env.PHOTO_EDITOR_E2E_DATA_DIR = dataDirectory;
const browserName = process.env.PHOTO_EDITOR_E2E_BROWSER || 'chromium';
if (!['chromium', 'firefox', 'webkit'].includes(browserName)) {
throw new Error(`Unsupported PHOTO_EDITOR_E2E_BROWSER: ${browserName}`);
}
module.exports = defineConfig({
testDir: path.join(__dirname, 'photo-editor'),
outputDir: path.join(__dirname, '..', '..', 'test-results', 'photo-editor'),
timeout: 90_000,
expect: { timeout: 10_000 },
fullyParallel: false,
workers: 1,
reporter: process.env.CI ? [['line'], ['html', { open: 'never' }]] : 'line',
globalSetup: require.resolve('./setup.js'),
globalTeardown: require.resolve('./teardown.js'),
use: {
baseURL,
browserName,
headless: true,
viewport: { width: 1440, height: 960 },
extraHTTPHeaders: { 'Accept-Encoding': 'identity' },
serviceWorkers: 'block',
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
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',
DATABASE_URL: `sqlite:///${databasePath}`,
ODYSSEUS_DATA_DIR: dataDirectory,
ODYSSEUS_STARTUP_WARMUPS: '0',
RESPONSE_COMPRESSION_ENABLED: 'false',
},
},
});
+10
View File
@@ -0,0 +1,10 @@
const fs = require('node:fs');
module.exports = async () => {
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
const databasePath = process.env.PHOTO_EDITOR_E2E_DB_PATH;
if (!dataDirectory || !databasePath) {
throw new Error('Photo editor E2E data paths were not configured');
}
fs.mkdirSync(dataDirectory, { recursive: true });
};
+7
View File
@@ -0,0 +1,7 @@
const fs = require('node:fs');
module.exports = async () => {
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
if (!dataDirectory) return;
fs.rmSync(dataDirectory, { recursive: true, force: true });
};
+69
View File
@@ -0,0 +1,69 @@
[
{
"id": "clean_v3_open_email_reply",
"kind": "draft",
"user": "Write reply this email saying 8am works for me",
"active_document": {
"title": "New Email",
"language": "email",
"content": "To: test@example.com\nSubject: Re: Meeting\nIn-Reply-To: <fixture@example.com>\nReferences: <fixture@example.com>\nX-Source-UID: 999999\n---\n\n---------- Previous message ----------\nCan you confirm the meeting time?\n"
},
"expect_first_tool": "update_document",
"forbidden_tools": [
"create_document",
"edit_document",
"suggest_document",
"manage_documents",
"web_search"
],
"must_mutate": "document_contains_8am"
},
{
"id": "clean_v3_open_email_short_reply",
"kind": "draft",
"user": "Write reply saying 8am works for me",
"active_document": {
"title": "Meeting",
"language": "email",
"content": "To: test@example.com\nSubject: Re: Meeting\nIn-Reply-To: <fixture-short@example.com>\nReferences: <fixture-short@example.com>\nX-Source-UID: 999998\n---\n\n---------- Previous message ----------\nCan you confirm the meeting time?\n"
},
"expect_first_tool": "update_document",
"forbidden_tools": [
"create_document",
"edit_document",
"suggest_document",
"manage_documents",
"web_search"
],
"must_mutate": "document_contains_8am"
},
{
"id": "clean_v3_open_email_unspecified_reply",
"kind": "draft",
"user": "Write reply to this email",
"active_document": {
"title": "Meeting",
"language": "email",
"content": "To: test@example.com\nSubject: Re: Meeting\nIn-Reply-To: <fixture-unspecified@example.com>\nReferences: <fixture-unspecified@example.com>\nX-Source-UID: 999997\n---\n\n---------- Previous message ----------\nCan you confirm whether tomorrow morning works?\n"
},
"expect_first_tool": "update_document",
"forbidden_tools": [
"create_document",
"edit_document",
"suggest_document",
"manage_documents",
"ui_control",
"web_search"
],
"forbidden_repeat_tools": ["update_document"],
"expect_document_changed": true,
"must_preserve_active_document_all": [
"To:",
"Subject:",
"In-Reply-To:",
"References:",
"X-Source-UID:",
"---"
]
}
]
+12
View File
@@ -0,0 +1,12 @@
{
"fixture": "basic-shapes.png",
"required_facts": {
"text": "ODYSSEUS 42",
"left_object": "red circle",
"right_object": "blue square"
},
"followup": {
"prompt": "What color was the shape on the right?",
"required_answer": "blue"
}
}
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="480" viewBox="0 0 640 480">
<rect width="640" height="480" fill="#ffffff"/>
<text x="320" y="90" text-anchor="middle" font-family="DejaVu Sans, sans-serif" font-size="52" font-weight="bold" fill="#111111">ODYSSEUS 42</text>
<circle cx="190" cy="285" r="90" fill="#e53935"/>
<rect x="370" y="195" width="180" height="180" fill="#1e5bd7"/>
</svg>

After

Width:  |  Height:  |  Size: 413 B

+38
View File
@@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Quarterly Sales Dashboard</title>
<style>
body { margin: 0; background: #eef2f7; color: #172033; font: 24px Arial, sans-serif; }
main { width: 960px; height: 640px; box-sizing: border-box; padding: 42px 56px; }
h1 { margin: 0 0 28px; font-size: 38px; }
.cards { display: flex; gap: 20px; margin-bottom: 34px; }
.card { background: white; border-radius: 14px; padding: 18px 24px; box-shadow: 0 3px 12px #17203320; }
.label { color: #64748b; font-size: 18px; }
.value { font-size: 30px; font-weight: 700; margin-top: 5px; }
.healthy { color: #14804a; }
.chart { height: 350px; display: flex; align-items: end; gap: 42px; padding: 0 45px; background: white; border-radius: 14px; box-shadow: 0 3px 12px #17203320; }
.column { width: 135px; text-align: center; font-weight: 700; }
.bar { background: #3976e8; border-radius: 10px 10px 0 0; color: white; padding-top: 10px; box-sizing: border-box; }
.q1 { height: 100px; } .q2 { height: 175px; } .q3 { height: 275px; background: #7c3aed; } .q4 { height: 200px; }
.quarter { padding: 12px 0 18px; color: #334155; }
</style>
</head>
<body>
<main>
<h1>Quarterly Sales Dashboard</h1>
<div class="cards">
<div class="card"><div class="label">Build status</div><div class="value healthy">Healthy</div></div>
<div class="card"><div class="label">API latency</div><div class="value">142 ms</div></div>
<div class="card"><div class="label">Active users</div><div class="value">1,284</div></div>
</div>
<div class="chart" aria-label="Quarterly sales: Q1 20, Q2 35, Q3 55, Q4 40">
<div class="column"><div class="bar q1">20</div><div class="quarter">Q1</div></div>
<div class="column"><div class="bar q2">35</div><div class="quarter">Q2</div></div>
<div class="column"><div class="bar q3">55</div><div class="quarter">Q3</div></div>
<div class="column"><div class="bar q4">40</div><div class="quarter">Q4</div></div>
</div>
</main>
</body>
</html>
@@ -20,6 +20,7 @@ const REAL_MODULES = new Set([
path.join(JS, 'settings/sidebar.js'),
path.join(JS, 'settings/navigation.js'),
path.join(JS, 'settings/lifecycle.js'),
path.join(JS, 'searchProviderIcons.js'),
]);
const realModulesLoaded = new Set();
+289
View File
@@ -0,0 +1,289 @@
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { chromium } from 'playwright';
// Execute the production browser entry functions with local network/session
// adapters. These are behavioral DOM tests, not assertions about source text.
const renderer = await readFile(new URL('../static/js/chatRenderer.js', import.meta.url), 'utf8');
const chat = await readFile(new URL('../static/js/chat.js', import.meta.url), 'utf8');
const addMessage = renderer.slice(renderer.indexOf('export function addMessage('), renderer.indexOf('\nconst chatRenderer =')).replace('export ', '');
const resume = chat.slice(chat.indexOf('export async function resumeStream('), chat.indexOf('\n /**\n * Check for background streams')).replace('export ', '');
let browser;
before(async () => { browser = await chromium.launch({ headless: true }); });
after(async () => { await browser?.close(); });
async function setup() {
const page = await browser.newPage();
await page.route('http://render.test/**', async route => {
const path = new URL(route.request().url()).pathname;
if (path === '/') return route.fulfill({ contentType: 'text/html', body: '<main id="chat-history"></main><button class="send-btn">Send</button>' });
if (path === '/static/js/ui.js') return route.fulfill({ contentType: 'text/javascript', body: 'export default window.uiModule;' });
if (!path.startsWith('/static/')) return route.abort();
const source = await readFile(new URL('..' + path, import.meta.url), 'utf8');
return route.fulfill({ contentType: 'text/javascript', body: source });
});
await page.goto('http://render.test/');
await page.evaluate(async ({ addMessage, resume }) => {
const noop = () => {};
const esc = value => { const node = document.createElement('div'); node.textContent = String(value ?? ''); return node.innerHTML; };
Object.assign(window, {
uiModule: { esc, scrollHistory: noop, showToast: value => window.toasts.push(value), showError: error => { throw Error(error); }, el: id => document.getElementById(id), captureHistoryScroll: () => ({ y: scrollY }), restoreHistoryScroll: s => scrollTo(0, s.y) },
hideWelcomeScreen: noop, resolveDocumentPlaceholderLinks: x => x,
replyModelPair: () => ({ actualModel: 'test', requestedModel: 'test' }),
modelRouteLabel: () => 'test', sameModelName: () => true, applyModelColor: noop,
roleTimestamp: () => document.createElement('span'),
createMsgFooter: () => { const node = document.createElement('div'); node.className = 'msg-footer'; node.textContent = 'Copy'; return node; },
displayMetrics: (node, metrics) => { node.dataset.metricsOwner = metrics.render_owner || ''; },
_suppressRawToolOutput: () => false, safeToolScreenshotSrc: () => '', _isPrivateBrowserTool: () => false,
_toolDisplayInfo: () => ({}), renderToolIcon: () => '',
buildSourcesBox: () => '<div class="sources-section">Sources</div>',
buildFindingsBox: () => '<div class="sources-section">Findings</div>',
buildRagSourcesBox: () => '<details class="rag-sources"><summary>Documents</summary></details>',
API_BASE: '', hasActiveStream: () => false,
_streamRunIds: new Map(), _resumingStreams: new Set(), _backgroundStreams: new Map(),
updateSubmitButton: noop, _shortModel: x => x, _applyModelColor: noop,
_streamDisplayText: x => x, createTerminalStreamError: x => Error(x.message || 'Stream error'),
_finishDocumentWritingStatus: noop,
spinnerModule: { create: () => { const node = document.createElement('span'); return { createElement: () => node, start: noop, destroy: () => node.remove() }; } },
reloads: 0, currentSession: 's', savedHistory: [], labels: [], toasts: [],
_setRoleModelLabel: (role, requested, actual) => { window.labels.push({ requested, actual }); role.textContent = requested + ' -> ' + actual; },
_metricsCostRecordId: () => 'test-run',
sessionModule: { getCurrentSessionId: () => window.currentSession, getSessions: () => [{ id: 's', model: 'test' }], markStreaming: noop, clearStreaming: noop, selectSession: () => { window.reloads++; }, loadSessions: () => { window.reloads++; } },
});
window.markdownModule = (await import('/static/js/markdown.js')).default;
markdownModule.renderMermaid = undefined;
window.createTurnRendering = (await import('/static/js/turnRendering.js')).createTurnRendering;
window.applyModelRouteEventState = (await import('/static/js/chatModelProvenance.js')).applyModelRouteEventState;
window.createTerminalStreamError = (await import('/static/js/chatStreamErrors.js')).createTerminalStreamError;
window.addMessage = (0, eval)('(' + addMessage + ')');
window.resumeStream = (0, eval)('(' + resume + ')');
window.chatRenderer = { addMessage: window.addMessage, recordSessionMetricsCost: noop, buildSourcesBox, buildFindingsBox, buildRagSourcesBox };
let controller;
const stream = new ReadableStream({ start(value) { controller = value; } });
window.send = event => controller.enqueue(new TextEncoder().encode('data: ' + (typeof event === 'string' ? event : JSON.stringify(event)) + '\n\n'));
window.sendError = event => controller.enqueue(new TextEncoder().encode('event: error\ndata: ' + JSON.stringify(event) + '\n\n'));
window.fetch = async url => String(url).includes('/api/chat/resume/')
? new Response(stream, { headers: { 'X-Odysseus-Run-Id': 'run-1' } })
: new Response(JSON.stringify({ history: window.savedHistory }));
}, { addMessage, resume });
return page;
}
test('history scoped ownership omits drafts for both owners, retains reasoning and tools, places answer last', async () => {
const page = await setup();
try {
for (const render_owner of ['structured', 'streamed']) {
const result = await page.evaluate(render_owner => {
document.querySelector('#chat-history').replaceChildren();
const metadata = { _fromHistory: true, _db_id: 42, render_owner, replacement_scope: 'turn', round_texts: ['<think>Lookup reasoning</think>Draft notes', 'Outdated answer'], tool_events: [{ round: 1, tool: 'manage_notes', output: 'Tool evidence', exit_code: 0 }] };
const original = JSON.stringify(metadata);
addMessage('assistant', '[Canonical note](#note-42)', 'test', metadata);
const root = document.querySelector('#chat-history');
return { text: root.textContent, link: root.querySelector('.body a')?.getAttribute('href'), unchanged: original === JSON.stringify(metadata), order: [...root.children].map(x => x.className), finalRaw: root.lastElementChild.dataset.raw };
}, render_owner);
assert.doesNotMatch(result.text, /Draft notes|Outdated answer/);
assert.match(result.text, /Lookup reasoning[\s\S]*Tool evidence[\s\S]*Canonical note/);
assert.equal(result.link, '#note-42');
assert.equal(result.unchanged, true);
assert.match(result.order.at(-1), /msg-ai/);
assert.equal(result.finalRaw, '[Canonical note](#note-42)');
}
} finally { await page.close(); }
});
test('legacy history keeps round prose without explicit replacement scope', async () => {
const page = await setup();
try {
const text = await page.evaluate(() => {
addMessage('assistant', 'Canonical', 'test', { _fromHistory: true, round_texts: ['Preamble', 'Old answer'], tool_events: [{ round: 1, tool: 'manage_notes' }] });
return document.querySelector('#chat-history').textContent;
});
assert.match(text, /Preamble[\s\S]*Canonical/);
} finally { await page.close(); }
});
async function startReplay(page) {
await page.evaluate(() => { window.running = resumeStream('s'); });
await page.waitForSelector('.msg-ai');
}
test('resume tool-only final is visible, scoped, and keeps the open timeline at completion', async () => {
const page = await setup();
try {
await startReplay(page);
await page.evaluate(() => {
send({ type: 'tool_start', tool: 'manage_notes', command: '{}' });
send({ type: 'tool_output', tool: 'manage_notes', output: 'Evidence', exit_code: 0 });
});
await page.waitForSelector('.agent-thread-node:not(.running)');
await page.evaluate(() => {
window.thread = document.querySelector('.agent-thread');
thread.querySelector('.agent-thread-node').classList.add('open');
send({ type: 'final_response', content: '[Canonical](#note-42)', render_owner: 'structured', replacement_scope: 'turn' });
});
await page.waitForSelector('.body a[href="#note-42"]', { state: 'visible' });
const result = await page.evaluate(async () => {
window.link = document.querySelector('.body a');
window.savedHistory = [{ role: 'assistant', content: '[Canonical](#note-42)', metadata: { _db_id: 42, render_owner: 'structured', replacement_scope: 'turn', round_texts: ['Stale draft', 'Canonical'], tool_events: [{ tool: 'manage_notes' }] } }];
send({ type: 'message_saved', id: 42, render_owner: 'structured' });
send('[DONE]');
await running;
return { sameThread: thread === document.querySelector('.agent-thread'), sameLink: link === document.querySelector('.body a'), open: thread.querySelector('.agent-thread-node').classList.contains('open'), reloads, count: document.querySelectorAll('.body a').length, id: link.closest('.msg-ai').dataset.dbId, text: document.querySelector('#chat-history').innerText };
});
assert.deepEqual({ ...result, text: undefined }, { sameThread: true, sameLink: true, open: true, reloads: 0, count: 1, id: '42', text: undefined });
assert.doesNotMatch(result.text, /Stale draft/);
} finally { await page.close(); }
});
test('resume preserves legitimate scoped synthesis and rejects unscoped conflicting prose', async () => {
const page = await setup();
try {
await startReplay(page);
const result = await page.evaluate(async () => {
send({ delta: 'Earlier draft', render_owner: 'streamed' });
send({ type: 'agent_step', round: 2 });
send({ type: 'final_response', content: 'Intermediate notes', render_owner: 'structured', replacement_scope: 'turn' });
send({ delta: 'Unscoped conflict', render_owner: 'streamed' });
send({ delta: 'Reasoning', thinking: true, render_owner: 'structured' });
send({ delta: 'Legitimate ', render_owner: 'streamed', replacement_scope: 'turn' });
send({ delta: 'synthesis', render_owner: 'streamed' });
send('[DONE]');
await running;
return { text: document.querySelector('#chat-history').innerText, reloads };
});
assert.match(result.text, /Legitimate synthesis/);
assert.doesNotMatch(result.text, /Earlier draft|Intermediate notes|Unscoped conflict|Reasoning/);
assert.equal(result.reloads, 0);
} finally { await page.close(); }
});
test('resume fallback and provider alias remain visible without a history reload', async () => {
const page = await setup();
try {
await startReplay(page);
const result = await page.evaluate(async () => {
send({ type: 'fallback', selected_model: 'selected-model', answered_by: 'fallback-model', reason: '429' });
send({ type: 'model_actual', model: 'provider/fallback-alias' });
send({ delta: 'hello' });
send('[DONE]');
await running;
return { labels, toasts, reloads, holders: document.querySelectorAll('.msg-ai').length, role: document.querySelector('.role').textContent, text: document.querySelector('.body').innerText };
});
assert.deepEqual(result.labels, [{ requested: 'selected-model', actual: 'fallback-model' }, { requested: 'selected-model', actual: 'provider/fallback-alias' }]);
assert.deepEqual(result.toasts, ['Fallback: selected-model failed — answered by fallback-model']);
assert.equal(result.reloads, 0);
assert.equal(result.holders, 1);
assert.equal(result.text, 'hello');
assert.match(result.role, /provider\/fallback-alias/);
} finally { await page.close(); }
});
test('resume preoutput provider error stays visible as escaped text without reload', async () => {
const page = await setup();
try {
await startReplay(page);
const result = await page.evaluate(async () => {
sendError({ status: 401, error: 'invalid key <img src=x>' });
send('[DONE]');
await running;
return { text: document.querySelector('.body').innerText, images: document.querySelectorAll('img').length, reloads };
});
assert.deepEqual(result, { text: '[Error: invalid key <img src=x>]', images: 0, reloads: 0 });
} finally { await page.close(); }
});
test('resume terminal failure reconciles exact saved partial without reloading tool timeline', async () => {
const page = await setup();
try {
await startReplay(page);
const result = await page.evaluate(async () => {
window.savedHistory = [
{ role: 'assistant', content: 'Correct partial [Agent stopped]', metadata: { _db_id: 42, render_owner: 'structured', replacement_scope: 'turn' } },
{ role: 'assistant', content: 'Wrong later answer', metadata: { _db_id: 43 } },
];
send({ delta: 'Partial' });
send({ type: 'message_saved', id: 42 });
send({ type: 'agent_terminal', data: { failure: { status: 429 } } });
sendError({ status: 429, error: 'Rate limited' });
send('[DONE]');
await running;
return { text: document.querySelector('.body').innerText, id: document.querySelector('.msg-ai').dataset.dbId, reloads };
});
assert.deepEqual(result, { text: 'Correct partial [Agent stopped]', id: '42', reloads: 0 });
} finally { await page.close(); }
});
test('resume stable and complete events clear all round markers while preserving streamed prose and tools', async () => {
const page = await setup();
try {
await startReplay(page);
await page.evaluate(() => {
send({ delta: 'First streamed round' });
send({ type: 'tool_start', tool: 'manage_notes' });
send({ type: 'tool_output', tool: 'manage_notes', output: 'Evidence', exit_code: 0 });
send({ type: 'agent_step', round: 2 });
send({ delta: 'Final streamed round' });
});
await page.waitForFunction(() => document.querySelector('#chat-history').innerText.includes('Final streamed round'));
await page.evaluate(() => {
window.prose = [...document.querySelectorAll('.body p')];
window.thread = document.querySelector('.agent-thread');
send({ type: 'stable' });
});
await page.waitForFunction(() => !document.querySelector('#chat-history .streaming'));
const result = await page.evaluate(async () => {
thread.classList.add('streaming');
send({ type: 'complete' });
send('[DONE]');
await running;
return { sameProse: prose.every(p => p.isConnected), sameThread: thread === document.querySelector('.agent-thread'), markers: document.querySelectorAll('#chat-history .streaming').length, reloads };
});
assert.deepEqual(result, { sameProse: true, sameThread: true, markers: 0, reloads: 0 });
} finally { await page.close(); }
});
test('resume structured final and completion remove all transient round bubbles and streaming markers', async () => {
const page = await setup();
try {
await startReplay(page);
await page.evaluate(() => {
send({ delta: 'Transient draft' });
send({ type: 'agent_step', round: 2 });
send({ delta: 'Another transient draft' });
send({ type: 'final_response', content: '[Canonical](#note-42)', render_owner: 'structured', replacement_scope: 'turn' });
});
await page.waitForSelector('.body a[href="#note-42"]', { state: 'visible' });
const result = await page.evaluate(async () => {
const stableMarkers = document.querySelectorAll('#chat-history .streaming').length;
const link = document.querySelector('.body a');
send('[DONE]');
await running;
return { stableMarkers, markers: document.querySelectorAll('#chat-history .streaming').length, bodies: document.querySelectorAll('.msg-ai .body').length, same: link === document.querySelector('.body a'), text: document.querySelector('#chat-history').innerText };
});
assert.equal(result.stableMarkers, 0);
assert.equal(result.markers, 0);
assert.equal(result.bodies, 1);
assert.equal(result.same, true);
assert.doesNotMatch(result.text, /Transient|transient/);
} finally { await page.close(); }
});
test('resume error clears earlier and current streaming markers without dropping partial prose', async () => {
const page = await setup();
try {
await startReplay(page);
const result = await page.evaluate(async () => {
send({ delta: 'First partial' });
send({ type: 'agent_step', round: 2 });
send({ delta: 'Second partial' });
sendError({ status: 500, error: 'Provider failure' });
send('[DONE]');
await running;
return { markers: document.querySelectorAll('#chat-history .streaming').length, text: document.querySelector('#chat-history').innerText, reloads };
});
assert.equal(result.markers, 0);
assert.equal(result.reloads, 0);
assert.match(result.text, /First partial[\s\S]*Second partial[\s\S]*Provider failure/);
} finally { await page.close(); }
});
+29
View File
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {expectedNoteTitles,compareNoteState} from '../scripts/note_test_oracle.mjs';
import {scoreCalls} from '../scripts/compare_schema_thinking.mjs';
const rows=[{id:'a123',title:'Japan',content:'body',pinned:false},{id:'b123',title:'Today',content:'keep'}];
const call=args=>({function:{name:'manage_notes',arguments:JSON.stringify(args)}});
test('shared expectations cover negation, exceptions and invalid cases',()=>{
assert.deepEqual(expectedNoteTitles('keep_all',['Japan','Today']),[]);
assert.deepEqual(expectedNoteTitles('contrast',['Groceries','Japan','Today']),['Groceries']);
assert.deepEqual(expectedNoteTitles('except_one',['Groceries','Japan','Today']),['Groceries','Today']);
assert.throws(()=>expectedNoteTitles('unregistered',[]));
});
test('state oracle detects edits and additions, not just disappearing IDs',()=>{
assert.equal(compareNoteState(rows,rows).unchanged,true);
assert.equal(compareNoteState(rows,[rows[1]],['a123']).exact,true);
assert.equal(compareNoteState(rows,[{...rows[1],content:'changed'}],['a123']).exact,false);
assert.deepEqual(compareNoteState(rows,[{...rows[0],pinned:true},rows[1]]).changed_fields,['pinned']);
assert.equal(compareNoteState(rows,[...rows,{id:'new',title:'extra'}]).added_count,1);
assert.equal(compareNoteState(rows,[{...rows[0],archived:true},rows[1]],['a123']).exact,false);
});
test('duplicate deletion cannot pass exact proposal check',()=>{
const result=scoreCalls([call({action:'delete',id:'a123'}),call({action:'delete',title:'Japan'})],rows,['Japan']);
assert.equal(result.exact_target_proposal,false);
assert.equal(result.duplicate_targets,1);
});
test('stale ID with valid unique title follows actual backend fallback',()=>{
assert.equal(scoreCalls([call({action:'delete',id:'stale',title:'Japan'})],rows,['Japan']).exact_target_proposal,true);
assert.equal(scoreCalls([{function:{name:'manage_notes',arguments:'null'}}],rows,[]).exact_target_proposal,false);
});
+40
View File
@@ -0,0 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { chromium } from 'playwright';
test('research primary actions use compact mobile sizing and retain desktop sizing', async () => {
const css = await readFile(new URL('../static/style.css', import.meta.url), 'utf8');
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setContent(`<div id="research-pane"><div id="research-past-list">
<div class="research-job-card"><div class="research-job-header">
<span class="research-job-query">A long research title that must still fit on a narrow phone</span>
<button class="task-status-badge research-job-report-badge" title="Open visual report"><svg></svg><span class="task-state-label">Visual Report</span></button>
<button class="task-status-badge research-job-discuss-badge" title="Discuss"><svg></svg><span class="task-state-label">Discuss</span></button>
</div></div></div></div>`);
await page.addStyleTag({ content: css });
for (const width of [320, 390, 600, 1024]) {
await page.setViewportSize({ width, height: 800 });
const buttons = await page.locator('.research-job-header button').evaluateAll(nodes => nodes.map(node => {
const rect = node.getBoundingClientRect();
return { width: rect.width, height: rect.height,
icon: node.querySelector('svg').getBoundingClientRect().width,
labelHidden: getComputedStyle(node.querySelector('.task-state-label')).display === 'none' };
}));
for (const button of buttons) {
if (width <= 600) {
assert.equal(button.width, 24);
assert.equal(button.height, 22);
assert.equal(button.icon, 10);
assert.equal(button.labelHidden, true);
} else {
assert.equal(button.height, 20);
assert.equal(button.icon, 10);
assert.equal(button.labelHidden, false);
}
}
}
} finally { await browser.close(); }
});
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {recordsIn,scoreCalls,auditHistory,reformatNoteResult} from '../scripts/compare_schema_thinking.mjs';
const records=[{id:'aaaaaaaa-0000-0000-0000-000000000000',title:'Japan'},
{id:'bbbbbbbb-0000-0000-0000-000000000000',title:'Today'}];
const call=(args)=>({function:{name:'manage_notes',arguments:JSON.stringify(args)}});
test('proposal grading resolves supported title and ID references, not concatenations',()=>{
assert.equal(scoreCalls([call({action:'delete',title:'Japan Today'})],records,['Japan','Today']).exact_target_proposal,false);
assert.equal(scoreCalls([call({action:'delete',id:'aaaaaaaa'}),call({action:'delete',title:'Today'})],records,['Japan','Today']).exact_target_proposal,true);
});
test('negative controls fail if any record is deleted',()=>{
assert.equal(scoreCalls([],records,[]).exact_target_proposal,true);
assert.equal(scoreCalls([call({action:'delete',title:'Japan'})],records,[]).exact_target_proposal,false);
});
test('missing targets are not hidden by repeated calls or exploratory reads',()=>{
const result=scoreCalls([call({action:'delete',title:'Japan'}),call({action:'delete',title:'Japan'}),call({action:'list'})],records,['Japan','Today']);
assert.equal(result.exact_target_proposal,false);
assert.equal(result.duplicate_targets,1);
assert.deepEqual(result.missing_targets,['Today']);
});
test('audit checks actual tool result bytes and native call pairing',()=>{
const result={role:'tool',tool_call_id:'c1',content:records.map(r=>`- [${r.id}] **${r.title}**`).join('\n')};
const messages=[{role:'user',content:'notes'},
{role:'assistant',tool_calls:[{id:'c1'}]},result,{role:'user',content:'delete both'}];
const request={messages,tools:[],chat_template_kwargs:{enable_thinking:false}};
assert.deepEqual(recordsIn(messages),records);
assert.equal(auditHistory(request,[{messages}],records.map(r=>r.id)).exact_prior_note_result_preserved,true);
const broken={...request,messages:[{...result,content:result.content+' changed'}]};
const audit=auditHistory(broken,[{messages}],records.map(r=>r.id));
assert.equal(audit.exact_prior_note_result_preserved,false);
assert.equal(audit.orphan_tool_results,1);
});
test('format-only variants retain IDs, titles, ordering, suffixes and wrapper fields',()=>{
const rows=[{...records[0],suffix:' [PINNED] #travel'},
{...records[1],title:'Today "special"',suffix:' [checklist] #todo'}];
const text=rows.map(r=>`- [${r.id}] **${r.title}**${r.suffix}`).join('\n');
const wrapped=JSON.stringify({results:text,exit_code:0,extra:'unchanged'});
const quoted=JSON.parse(reformatNoteResult(wrapped,'quoted'));
assert.equal(quoted.extra,'unchanged');
assert.equal(quoted.exit_code,0);
assert.equal(quoted.results,rows.map(r=>`- [${r.id}] ${JSON.stringify(r.title)}${r.suffix}`).join('\n'));
const jsonl=JSON.parse(reformatNoteResult(wrapped,'jsonl'));
assert.deepEqual(jsonl.results.split('\n').map(l=>JSON.parse(l)),rows);
assert.throws(()=>reformatNoteResult(text+'\nAdditional body text','jsonl'));
});
+24
View File
@@ -0,0 +1,24 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { skillsSummaryMetrics, filterSkillsByQuickFilter, skillNeedsReview } from '../static/js/skillsMetrics.js';
test('approval uses the configured threshold for every item, not array index', () => {
const rows = ['one', 'two', 'three'].map(name => ({ name, status: 'published', audit_verdict: 'pass', confidence: 0.9, baseline_verdict: 'same' }));
assert.equal(skillsSummaryMetrics(rows, 0.85).approved, 3);
assert.equal(skillsSummaryMetrics(rows, 0.85).review, 0);
assert.equal(filterSkillsByQuickFilter(rows, 'approved', 0.95).length, 0);
assert.equal(filterSkillsByQuickFilter(rows, 'draft', 0.95).length, 3);
});
test('review badges agree with approval and keep unaudited skills queued', () => {
assert.equal(skillNeedsReview({ status: 'draft' }), false);
assert.equal(skillNeedsReview({ status: 'draft', audit_verdict: 'pass', confidence: 0.95 }), true);
assert.equal(skillNeedsReview({ status: 'published', audit_verdict: 'pass', confidence: 0.95, necessity: { necessary: false } }), true);
assert.equal(skillNeedsReview({ status: 'published', source: 'builtin' }), false);
});
test('built-ins and drafts have distinct filters and archived skills remain recoverable', () => {
const rows = [{ name: 'shipped', source: 'builtin', status: 'published' }, { name: 'bad', status: 'binned' }];
assert.deepEqual(filterSkillsByQuickFilter(rows, 'builtin').map(s => s.name), ['shipped']);
assert.deepEqual(filterSkillsByQuickFilter(rows, 'draft').map(s => s.name), ['bad']);
});
+70 -1
View File
@@ -13,6 +13,7 @@ def test_calendar_imperative_variants_promote_to_agent():
assert message_needs_tools("schedule a call with Mina next Friday")
assert message_needs_tools("put dentist appointment on my calendar")
assert message_needs_tools("Alright. Recreate that same appointment")
assert message_needs_tools("delete that actually")
assert message_needs_tools("Okay delete that doctor appointment from the calendar")
assert message_needs_tools("have another go at adding a test entry to the calendar")
assert message_needs_tools(
@@ -56,12 +57,25 @@ def test_explicit_web_search_promotes_to_agent():
assert classify_tool_intent("use web search and find a recipe").category == "web"
def test_chinese_web_lookup_requests_route_to_web_tools():
intent = classify_tool_intent("帮我查一下这些店铺的地址,我要去打卡")
assert intent.needs_tools
assert intent.category == "web"
def test_nearest_place_lookup_promotes_to_web_agent():
intent = classify_tool_intent("from vasaplan stockholm where is closest parking")
assert intent.needs_tools
assert intent.category == "web"
def test_workspace_agent_requests_promote_to_shell_workspace():
prompts = [
"fix the bug in this repo",
"run the tests for this project",
"debug the server logs",
"run terminal-bench on this task",
"run a performance benchmark on this project",
"inspect the traceback and patch the code",
]
for prompt in prompts:
@@ -70,6 +84,61 @@ def test_workspace_agent_requests_promote_to_shell_workspace():
assert intent.category == "workspace"
def test_page_references_are_not_mistaken_for_named_computers():
for prompt in (
"What heading is visible on that page?",
"Read it from the current page.",
"Compare this with the same page.",
):
intent = classify_tool_intent(prompt)
assert intent.category != "workspace"
intent = classify_tool_intent("check the service on odysseus")
assert intent.needs_tools and intent.category == "workspace"
def test_direct_code_requests_promote_to_workspace_agent():
prompts = [
"write a Python function that parses CSV",
"write answer.json",
"create app.ts",
"edit src/app.py",
"Can you create a script in this project?",
"edit the React component to show a loading state",
"I want you to build a small command-line tool",
"Can you code this in the repo?",
]
for prompt in prompts:
intent = classify_tool_intent(prompt)
assert intent.needs_tools
assert intent.category == "workspace"
def test_code_explanations_stay_plain_chat():
assert not message_needs_tools("How do I write a Python function?")
assert not message_needs_tools("Can you explain how a React component works?")
def test_shell_diagnostic_commands_promote_to_agent():
prompts = [
"lsblk",
"run lsblk",
"df -h",
"docker ps",
"nvidia-smi",
"can you run journalctl -u odysseus",
]
for prompt in prompts:
intent = classify_tool_intent(prompt)
assert intent.needs_tools
assert intent.category in {"shell", "workspace"}
def test_shell_command_explanations_stay_plain_chat():
assert not message_needs_tools("How do I use lsblk?")
assert not message_needs_tools("Can you explain docker ps?")
def test_explanatory_calendar_questions_stay_plain_chat():
assert not message_needs_tools("How do I add an entry to my calendar?")
assert not message_needs_tools("What about the built-in Odysseus calendar, is that linked to email?")
+23
View File
@@ -0,0 +1,23 @@
from src.action_intents import classify_tool_intent
def test_open_cal_promotes_to_ui_panel():
intent = classify_tool_intent("open cal")
assert intent.needs_tools
assert intent.category == "ui"
def test_terse_dated_calendar_create_promotes_to_calendar():
intent = classify_tool_intent("add fireworks october 3rd")
assert intent.needs_tools
assert intent.category == "calendar"
def test_terse_timed_calendar_create_promotes_to_calendar():
intent = classify_tool_intent("schedule dinner next friday 7pm")
assert intent.needs_tools
assert intent.category == "calendar"
+408
View File
@@ -0,0 +1,408 @@
from pathlib import Path
import subprocess
ROOT = Path(__file__).resolve().parents[1]
def test_shared_action_menu_order_is_used_by_item_menus() -> None:
expected_imports = {
"static/js/documentLibrary.js": "orderActionMenuItems",
"static/js/tasks.js": "orderActionMenuItems",
"static/js/sessions.js": "orderActionMenuItems",
"static/js/research/panel.js": "orderActionMenuItems",
"static/js/emailLibrary.js": "orderActionMenuItems",
"static/js/memory.js": "orderActionMenuItems",
}
for relative_path, helper in expected_imports.items():
source = (ROOT / relative_path).read_text(encoding="utf-8")
assert "actionMenuOrder.js" in source
assert helper in source
def test_common_action_order_matches_product_convention() -> None:
source = (ROOT / "static/js/actionMenuOrder.js").read_text(encoding="utf-8")
for rank in (200, 400, 500, 550, 600, 650, 700, 900, 1000):
assert f"{{ rank: {rank}" in source
def test_callback_actions_are_sorted_by_their_labels() -> None:
script = """
import { orderActionMenuItems } from './static/js/actionMenuOrder.js';
const callback = () => {};
const items = [
{ label: 'Delete', action: callback },
{ label: 'Archive', action: callback },
{ label: 'Copy', action: callback },
{ label: 'Favorite', action: callback },
{ label: 'Select', action: callback },
{ label: 'Rename', action: callback },
{ label: 'Open', action: callback },
{ label: 'Cancel', action: callback },
];
process.stdout.write(orderActionMenuItems(items).map(item => item.label).join('|'));
"""
result = subprocess.run(
["node", "--input-type=module", "--eval", script],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
assert result.stdout == "Open|Rename|Favorite|Copy|Select|Archive|Delete|Cancel"
def test_action_order_module_is_precached() -> None:
service_worker = (ROOT / "static/sw.js").read_text(encoding="utf-8")
assert "'/static/js/actionMenuOrder.js'" in service_worker
def test_dropdown_select_actions_use_the_canonical_icon() -> None:
source = (ROOT / "static/js/actionMenuOrder.js").read_text(encoding="utf-8")
assert "export const SELECT_MENU_ICON" in source
for relative_path in (
"static/js/documentLibrary.js",
"static/js/memory.js",
"static/js/sessions.js",
"static/js/skills.js",
"static/js/tasks.js",
"static/js/emailLibrary.js",
"static/js/research/panel.js",
):
module = (ROOT / relative_path).read_text(encoding="utf-8")
assert "SELECT_MENU_ICON" in module
def test_email_filter_menu_has_context_title() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
assert 'email-filter-menu-title">Filter by...</div>' in source
def test_email_setting_toggles_render_neutral_disabled_state() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
assert 'email-settings-auto-reply-section' in source
assert 'email-settings-display-enabled-state' in source
assert 'stateLabel = section?.querySelector' in source
assert '.email-settings-section.is-disabled .email-settings-enabled-state' in style
def test_email_search_options_menu_has_context_title() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
menu_start = source.index('id="email-search-options-menu"')
menu_end = source.index("</div>", menu_start) + len("</div>")
assert 'email-search-options-title">Filter by...</div>' in source[menu_start:menu_end]
def test_email_date_headers_mark_unexpected_timeline_gaps() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
assert "function _emailTimelineGapThreshold(items)" in source
assert "email-date-gap-break" in source
assert "gapDays > 90 && gapDays > timelineGapThreshold" in source
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
assert ".date-section-header.email-date-gap-break" in style
def test_email_filters_and_card_favorite_toggle_are_wired() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
assert '<option value="tag:action-needed">' not in source
assert "filter:tag:action-needed" not in source
assert "email-card-favorite" in source
assert "aria-pressed" in source
assert "/api/email/flag/" in source
assert "Object.prototype.hasOwnProperty.call(em, 'is_flagged')" in source
assert "const favoritesView = state._libFilter === 'favorites';" in source
assert "statusCluster.insertBefore(favoriteToggle, doneControl)" in source
assert "statusCluster.className = 'email-card-status';" in source
assert "statusCluster.appendChild(att)" in source
assert "statusCluster.appendChild(doneCheck)" in source
assert "function _exactTypedFilterSuggestion(value)" in source
assert "opt.value === 'filter:has-attachments'" in source
assert "const typedFilter = _exactTypedFilterSuggestion(v);" in source
assert "_acceptSuggestion(typedFilter);" in source
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
favorite_start = style.index(".email-card-favorite {")
favorite_end = style.index("}", favorite_start) + 1
assert "top: -3px;" in style[favorite_start:favorite_end]
assert ".email-card-status" in style
def test_email_auto_reply_start_date_seeds_today_when_picker_opens() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
assert "function _todayDateInputValue()" in source
assert "if (autoReplyStart && !autoReplyStart.value) autoReplyStart.value = _todayDateInputValue();" in source
assert "autoReplyStart?.addEventListener('pointerdown', seedAutoReplyStartDate);" in source
assert "autoReplyStart?.addEventListener('focus', seedAutoReplyStartDate);" in source
def test_email_auto_reply_syncs_one_calendar_event_per_account() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
assert "function _syncAutoReplyCalendarEvent(cfg)" in source
assert "summary: 'Email Auto Reply (away)'" in source
assert "function _findAutoReplyCalendarEventUids(cfg, accountId)" in source
assert "Odysseus email auto reply - account:" in source
assert "_AUTO_REPLY_CALENDAR_KEY_PREFIX" in source
assert "method: 'POST', body: JSON.stringify(payload)" in source
assert "method: 'PUT', body: JSON.stringify(payload)" in source
assert "method: 'DELETE'" in source
assert "all_day: true" in source
assert "await _syncAutoReplyCalendarEvent(savedCfg)" in source
assert "_syncAutoReplyCalendarEvent(cfg).catch" in source
def test_email_settings_show_away_account_and_compact_display_controls() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
style = (ROOT / "static/style.css").read_text(encoding="utf-8")
assert 'email-account-away-label">(AWAY)</span>' in source
assert 'id="email-lib-auto-reply-badge"' in source
assert ">Show Email Tags</span>" in source
assert "enabled ? 'Show' : 'Hide'" in source
assert "email-settings-inline-link" in source
assert "email-auto-reply-exclude" not in source
assert "_emailWritingStyleHtml(writingStyle) + _emailDisplaySettingsHtml()" in source
assert ".email-settings-status.is-success" in style
assert "var(--color-success, #4caf50)" in style
assert ".email-style-settings-extract svg" in style
assert "export async function mountEmailSettings(host)" in source
assert "_openGlobalEmailSettings('show-tags')" in source
assert source.count('class="admin-card email-settings-section') == 4
assert 'id="settings-email-default-card"' in (ROOT / "static/index.html").read_text(encoding="utf-8")
assert "multipleAccounts" in source
def test_email_cleanup_uses_the_memory_tidy_star_icon() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
cleanup = source[source.index("function _emailCleanupSettingsHtml"):source.index("function _emailDisplaySettingsHtml")]
assert "email-settings-clean-btn" in cleanup
assert "M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z" in cleanup
def test_email_settings_escape_returns_to_email_list() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
settings_guard = "if (modal.classList.contains('email-settings-mode'))"
assert settings_guard in source
assert source.index(settings_guard) < source.index("closeEmailLibrary();", source.index(settings_guard))
assert "_hideEmailSettingsPage();" in source[source.index(settings_guard):source.index(settings_guard) + 180]
def test_email_select_escape_cancels_selection_without_closing_library() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
select_guard = "if (state._selectMode) {"
select_start = source.index(select_guard, source.index("if (e.key === 'Escape')"))
assert "_setSelectBtnState(false);" in source[select_start:select_start + 260]
assert "closeEmailLibrary();" not in source[select_start:select_start + 260]
def test_chat_delete_actions_use_the_shared_trash_bin_icon() -> None:
source = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8")
assert "const TRASH_ICON =" in source
assert "{ id: 'delete', icon: TRASH_ICON" in source
assert source.count("{ id: 'delete', icon: TRASH_ICON") == 2
assert "M3 6h18" in source
def test_agent_unsubscribe_uses_the_reviewed_target_without_rescanning() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
start = source.index("function _askAgentToUnsubscribe")
end = source.index("function _unsubscribeCandidateUids", start)
prompt = source[start:end]
assert "private_browser" in prompt
assert "Do not call scan_email_unsubscribes again" in prompt
assert "Reviewed method_index" in prompt
assert "Exact unsubscribe URL" in prompt
assert "bulk_email action=delete" in prompt
assert "Email UID(s)" in prompt
def test_email_clean_always_forces_a_fresh_unsubscribe_scan() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
start = source.index("function _bindEmailSettingsPageControls")
end = source.index("function _setUnsubButtonBusy", start)
controls = source[start:end]
assert "_openUnsubscribeReviewModal(ev.currentTarget, { forceRescan: true })" in controls
assert "statusEl.style.justifyContent = 'flex-end'" in source
def test_unsubscribe_duplicate_badge_is_lowered() -> None:
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
stylesheet = (ROOT / "static/style.css").read_text(encoding="utf-8")
assert "email-unsub-duplicate-badge" in frontend
start = stylesheet.index(".email-unsub-duplicate-badge {")
assert "top: 2px;" in stylesheet[start:stylesheet.index("}", start) + 1]
def test_unsubscribe_scan_status_sits_before_clean_action() -> None:
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
stylesheet = (ROOT / "static/style.css").read_text(encoding="utf-8")
start = frontend.index("function _emailCleanupSettingsHtml")
end = frontend.index("function _emailDisplaySettingsHtml", start)
cleanup = frontend[start:end]
assert "email-settings-clean-actions" in cleanup
assert cleanup.index("email-settings-clean-status") < cleanup.index("email-settings-clean-btn")
assert "inlineHost.querySelector('.email-settings-clean-status')" in frontend
css_start = stylesheet.index(".email-settings-clean-status {")
assert "width: auto !important;" in stylesheet[css_start:stylesheet.index("}", css_start) + 1]
assert "email-unsub-panel-status" in frontend
assert "modal.style.cssText = 'display:none;margin-top:10px;'" in frontend
assert "showFinalStatus(finalStatus)" in frontend
assert "email-unsub-delete-all-btn').style.display = candidates.length ? 'inline-flex' : 'none'" in frontend
assert "statusEl.classList.add('is-busy')" in frontend
assert ".email-settings-clean-actions:has(.email-settings-clean-status.is-busy)" in stylesheet
panel_css = stylesheet[stylesheet.index(".email-unsub-panel-status {"):]
assert "top: 2px;" in panel_css[:panel_css.index("}") + 1]
def test_unsubscribe_success_removes_messages_before_the_next_scan() -> None:
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
backend = (ROOT / "routes/email_routes.py").read_text(encoding="utf-8")
mcp = (ROOT / "mcp_servers/email_server.py").read_text(encoding="utf-8")
assert "async function _deleteAfterUnsubscribe" in frontend
assert "action: 'delete'" in frontend[frontend.index("async function _deleteAfterUnsubscribe"):]
execute = backend[backend.index('@router.post("/unsubscribe/execute")'):]
assert 'deleted = _move_email_message(conn, uid, "Trash", role="trash")' in execute
assert '"deleted": deleted' in execute
unsubscribe = mcp[mcp.index("def _unsubscribe_email"):mcp.index("def _extract_text", mcp.index("def _unsubscribe_email"))]
assert "_delete_email(uid, folder=folder, account=account)" in unsubscribe
assert '"deleted": deleted' in unsubscribe
def test_agent_email_mutations_reconcile_bulk_single_and_mailto_results() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
start = source.index("function _agentDeletedEmailUids")
end = source.index("function _handleAgentEmailToolOutput", start)
resolver = source[start:end]
assert "tool.includes('bulk_email')" in resolver
assert "tool.endsWith('delete_email')" in resolver
assert "source email moved to trash" in resolver
assert "data.uid" in resolver
def test_browser_agent_unsubscribe_cleans_sender_after_positive_confirmation() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
start = source.index("function _agentBrowserUnsubscribeSucceeded")
end = source.index("function _agentDeletedEmailUids", start)
browser_flow = source[start:end]
assert "already\\s+unsubscribed" in browser_flow
assert "_deleteAfterUnsubscribe([candidate])" in browser_flow
assert "scope: group.sender ? 'sender_unsubscribe'" in source
assert "cleanupInFlight" in browser_flow
def test_auto_unsubscribe_all_is_visibly_taller_than_toolbar_buttons() -> None:
source = (ROOT / "static/style.css").read_text(encoding="utf-8")
start = source.index(".email-unsub-auto-safe-btn {")
assert "height: 29px;" in source[start:source.index("}", start) + 1]
def test_email_mutation_tool_events_include_exact_arguments() -> None:
source = (ROOT / "src/agent_loop.py").read_text(encoding="utf-8")
start = source.index("# Emit tool_output (include ui_event data if present)")
end = source.index("if tool_call_id:", start)
event = source[start:end]
assert '"mcp__email__bulk_email"' in event
assert '"mcp__email__delete_email"' in event
assert '"mcp__email__unsubscribe_email"' in event
assert '"private_browser"' in event
assert 'tool_output_data["tool_args"]' in event
def test_unsubscribe_cleanup_can_remove_same_sender_unsubscribe_messages() -> None:
source = (ROOT / "routes" / "email_routes.py").read_text()
cleanup = source[source.index('@router.post("/unsubscribe/cleanup")'):source.index('@router.get("/contacts")')]
assert 'scope == "sender_unsubscribe"' in cleanup
assert "_unsubscribe_sender_uids_sync" in cleanup
sender_scan = source[source.index("def _unsubscribe_sender_uids_sync"):source.index('@router.get("/unsubscribe/scan")')]
assert "FROM {_imap_search_quote(sender_key)}" in sender_scan
assert 'candidate.get("from_address")' in source[source.index("def _unsubscribe_sender_uids_sync"):source.index('@router.get("/unsubscribe/scan")')]
def test_unsubscribe_review_marks_handled_cards_and_offers_scan_further() -> None:
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
start = source.index("function _markUnsubscribeCardDone")
end = source.index("async function _runUnsubscribeCleanup", start)
card = source[start:end]
assert "_UNSUB_CHECK_ICON" in card
assert "is-unsubscribed" in card
assert "email-unsub-scan-further" in source
def test_unsubscribe_review_can_ignore_a_candidate_without_deleting_it() -> None:
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
styles = (ROOT / "static" / "style.css").read_text()
assert "email-unsub-ignore-btn" in source
assert "_rememberUnsubscribeIgnored(c)" in source
assert "Ignore this unsubscribe candidate" in source
assert ".email-unsub-ignore-btn" in styles
def test_email_settings_sections_use_static_headers() -> None:
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
styles = (ROOT / "static" / "style.css").read_text()
assert 'class="email-unsub-accent-icon"' in source
assert 'M12 0L14.59 8.41' in source
assert 'Scanning ${_esc(scanFolderLabel)} headers…' in source
assert 'email-settings-clean-btn' in source
assert 'const inlineHost = settingsPage?.querySelector?.(\'.email-settings-cleanup-section\')' in source
assert '<div class="admin-card email-settings-section' in source
assert 'class="email-settings-section-head"' in source
assert '<div id="email-settings-cleanup-body" class="email-settings-section-body">' in source
assert '<details class="admin-card email-settings-section' not in source
assert source.count('id="email-auto-reply-enabled"') == 1
assert source.count('id="email-settings-show-tags"') == 1
assert 'flex: 0 0 auto;' in styles
assert 'page.querySelectorAll(\'details.email-settings-section\')' not in source
assert 'other.open = false' not in source
assert '.email-settings-section[open] > .email-settings-section-body' in styles
assert '.email-settings-section[open] > .email-settings-section-body > *' in styles
assert 'flex: 0 0 auto;' in styles
assert '.email-settings-section[open] {' in styles
assert 'flex: 1 1 auto;' in styles
assert 'grid-template-rows: auto minmax(0, 1fr);' in styles
assert 'height: 100%;' in styles
assert 'max-height: 100%;' in styles
assert 'overflow: hidden;' in styles
assert '.modal-content:not([style*="height"])' in styles
assert 'left: -1px;' in styles
assert '.email-settings-clean-status:not(:empty)' in styles
assert '.email-unsub-status.is-error' in styles
def test_unsubscribe_scan_defaults_to_bounded_page_in_api_and_tool_prompt() -> None:
backend = (ROOT / "routes" / "email_routes.py").read_text()
schema = (ROOT / "src" / "tool_schemas.py").read_text()
agent = (ROOT / "src" / "agent_loop.py").read_text()
scan_start = backend.index('@router.get("/unsubscribe/scan")')
scan_end = backend.index('@router.post("/unsubscribe/execute")', scan_start)
scan_route = backend[scan_start:scan_end]
assert 'max_scan: int = Query(500)' in scan_route
assert 'max_scan = max(limit, min(requested_max_scan or 500, 500))' in backend
assert 'for start in range(0, len(uids), 100)' in backend
assert 'capped at 500' in schema
assert '"max_scan": 500' in agent[agent.index('def _parse_qwen_explicit_unsubscribe_scan_request'):agent.index('def _parse_qwen_explicit_unsubscribe_email_request')]
mcp = (ROOT / "mcp_servers" / "email_server.py").read_text()
mcp_scan = mcp[mcp.index('def _scan_unsubscribe_candidates'):mcp.index('def _unsubscribe_email')]
assert 'max_scan=500' in mcp_scan
assert 'for start in range(0, len(uids), 100)' in mcp_scan
assert 'capped at 500' in mcp
assert 'Scan up to 500 newest email headers' in mcp
def test_item_menus_share_the_standard_dropdown_classes() -> None:
expected = {
"static/js/documentLibrary.js": "dropdown session-dropdown-menu doclib-card-dropdown",
"static/js/memory.js": "dropdown session-dropdown-menu memory-item-dropdown",
"static/js/tasks.js": "dropdown session-dropdown-menu task-dropdown",
"static/js/skills.js": "dropdown session-dropdown-menu skill-kebab-menu",
}
for relative_path, class_names in expected.items():
source = (ROOT / relative_path).read_text(encoding="utf-8")
assert class_names in source
def test_task_card_menu_can_enter_select_mode_with_current_task() -> None:
source = (ROOT / "static/js/tasks.js").read_text(encoding="utf-8")
assert "function _taskEnterSelectWith(taskId)" in source
assert "label: 'Select'" in source
assert "action: () => _taskEnterSelectWith(task.id)" in source
@@ -0,0 +1,31 @@
from pathlib import Path
SCRIPT = (
Path(__file__).resolve().parents[1] / "src/agent_loop.py"
).read_text(encoding="utf-8")
def test_active_document_mutations_require_editor_tool_evidence():
assert "def _active_document_mutation_requires_tool(" in SCRIPT
assert "def _has_successful_active_document_mutation(" in SCRIPT
assert "_active_document_mutation_turn" in SCRIPT
assert "active document mutation answered without editor tool evidence" in SCRIPT
def test_guard_allows_editor_tools_or_one_clarification():
assert '{"edit_document", "update_document", "suggest_document"}' in SCRIPT
assert "call `ask_user` once instead" in SCRIPT
def test_active_email_reply_drafts_are_editor_mutations():
assert "_is_email_document_obj(active_document) and _email_reply_draft_requested(text)" in SCRIPT
def test_guard_uses_request_tools_before_retrieval_tools_are_initialized():
call = """_active_document_mutation_requires_tool(
_last_user,
active_document,
relevant_tools,
)"""
assert call in SCRIPT
+282
View File
@@ -0,0 +1,282 @@
import asyncio
def test_new_tmux_session_forwards_runtime_python_environment(monkeypatch):
from src.agent_tools import subprocess_tools
calls = []
checks = 0
async def fake_has_session(_name):
nonlocal checks
checks += 1
return checks > 1
async def fake_run_exec(*args, **kwargs):
calls.append(args)
if args[:2] == ("tmux", "has-session"):
return "", "", 0
return "", "", 0
monkeypatch.setattr(subprocess_tools, "_tmux_has_session", fake_has_session)
monkeypatch.setattr(subprocess_tools, "_run_exec", fake_run_exec)
asyncio.run(subprocess_tools._ensure_tmux_session(
"ody-test",
"/workspace",
{
"PATH": "/opt/ody/bin:/usr/bin",
"VIRTUAL_ENV": "/opt/ody",
"HOME": "/workspace",
"TMPDIR": "/workspace/.tmp",
"SECRET": "must-not-forward",
},
))
new_session = next(call for call in calls if call[:2] == ("tmux", "new-session"))
assert "PATH=/opt/ody/bin:/usr/bin" in new_session
assert "VIRTUAL_ENV=/opt/ody" in new_session
assert "HOME=/workspace" in new_session
assert "TMPDIR=/workspace/.tmp" in new_session
assert not any("SECRET=" in arg for arg in new_session)
def test_reused_tmux_session_refreshes_runtime_python_environment(monkeypatch):
from src.agent_tools import subprocess_tools
sent = []
async def fake_has_session(_name):
return True
async def fake_send_line(name, line):
sent.append((name, line))
async def fake_run_exec(*_args, **_kwargs):
return "", "", 0
monkeypatch.setattr(subprocess_tools, "_tmux_has_session", fake_has_session)
monkeypatch.setattr(subprocess_tools, "_tmux_send_line", fake_send_line)
monkeypatch.setattr(subprocess_tools, "_run_exec", fake_run_exec)
asyncio.run(subprocess_tools._ensure_tmux_session(
"ody-existing",
"/workspace",
{"PATH": "/path with spaces/bin:/usr/bin", "VIRTUAL_ENV": "/path with spaces"},
))
assert sent == [(
"ody-existing",
"export PATH='/path with spaces/bin:/usr/bin' VIRTUAL_ENV='/path with spaces'",
)]
def test_workspace_alias_rewrite_does_not_duplicate_absolute_host_path():
from src.agent_tools.subprocess_tools import _replace_workspace_alias
cwd = "/runs/task/workspace"
command = "cd /workspace && python /runs/task/workspace/plot.py"
assert _replace_workspace_alias(command, cwd) == (
"cd /runs/task/workspace && python /runs/task/workspace/plot.py"
)
def test_workspace_namespace_preserves_literal_paths_inside_scripts(tmp_path):
from pathlib import Path
import subprocess
from src.agent_tools.subprocess_tools import _wrap_workspace_namespace
command = _wrap_workspace_namespace(
"python -c 'from pathlib import Path; Path(\"/workspace/result.txt\").write_text(\"ok\")'",
str(tmp_path),
)
if command is None:
return
subprocess.run(command, shell=True, check=True)
assert (Path(tmp_path) / "result.txt").read_text() == "ok"
def test_tmux_bash_runs_tool_command_with_closed_stdin(monkeypatch, tmp_path):
from src.agent_tools import subprocess_tools
sent = []
async def fake_ensure(*_args, **_kwargs):
return None
async def fake_send(_name, line):
sent.append(line)
async def fake_capture(_name):
start = next(line for line in sent if "__ODYSSEUS_CMD_START_" in line)
start = start.split("\\n")[1]
end_line = next(line for line in sent if "__ODYSSEUS_CMD_END_" in line)
end = end_line.split("\\n")[1].split("%s")[0]
return f"{start}\ngot-eof\n{end}0\n"
monkeypatch.setattr(subprocess_tools, "_ensure_tmux_session", fake_ensure)
monkeypatch.setattr(subprocess_tools, "_tmux_send_line", fake_send)
monkeypatch.setattr(subprocess_tools, "_tmux_capture", fake_capture)
monkeypatch.setattr(subprocess_tools.time, "time", lambda: 0.123456)
output, _stderr, rc, timed_out = asyncio.run(
subprocess_tools._run_tmux_bash(
"if read answer; then echo unexpected; else echo got-eof; fi",
session_id="test",
cwd=str(tmp_path),
env={},
timeout=2,
)
)
assert output == "got-eof"
assert rc == 0
assert timed_out is False
assert any("/bin/bash -lc" in line and "</dev/null" in line for line in sent)
def test_tmux_bash_timeout_destroys_session_process_tree(monkeypatch, tmp_path):
from src.agent_tools import subprocess_tools
exec_calls = []
sent = []
clock = [-2.0]
async def fake_ensure(*_args, **_kwargs):
return None
async def fake_send(name, line):
sent.append((name, line))
async def fake_capture(_name):
return "still running"
async def fake_run_exec(*args, **kwargs):
exec_calls.append(args)
return "", "", 0
async def fake_sleep(_seconds):
return None
monkeypatch.setattr(subprocess_tools, "_ensure_tmux_session", fake_ensure)
monkeypatch.setattr(subprocess_tools, "_tmux_send_line", fake_send)
monkeypatch.setattr(subprocess_tools, "_tmux_capture", fake_capture)
monkeypatch.setattr(subprocess_tools, "_run_exec", fake_run_exec)
monkeypatch.setattr(subprocess_tools.asyncio, "sleep", fake_sleep)
def fake_time():
clock[0] += 2.0
return clock[0]
monkeypatch.setattr(subprocess_tools.time, "time", fake_time)
_output, _stderr, rc, timed_out = asyncio.run(
subprocess_tools._run_tmux_bash(
"grep -r needle /large/tree",
session_id="timeout-test",
cwd=str(tmp_path),
env={},
timeout=1,
)
)
assert rc == 124 and timed_out is True
assert any(call[:3] == ("tmux", "send-keys", "-t") for call in exec_calls)
assert any(call[:3] == ("tmux", "kill-session", "-t") for call in exec_calls)
def test_direct_bash_subprocess_has_closed_stdin(monkeypatch, tmp_path):
from src.agent_tools import subprocess_tools
from src import tool_execution
captured = {}
sentinel = object()
async def fake_create(command, **kwargs):
captured.update(kwargs)
return sentinel
async def fake_stream(proc, **_kwargs):
assert proc is sentinel
return "ok", "", 0, False
monkeypatch.setattr(asyncio, "create_subprocess_shell", fake_create)
monkeypatch.setattr(subprocess_tools, "_run_subprocess_streaming", fake_stream)
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(tmp_path))
result = asyncio.run(subprocess_tools.BashTool().execute("echo ok", {}))
assert result["exit_code"] == 0
assert captured["stdin"] is asyncio.subprocess.DEVNULL
assert not (tmp_path / ".tmp").exists()
def test_agent_bash_timeout_is_bounded_for_interactive_runs():
from src.agent_tools import subprocess_tools
assert subprocess_tools.DEFAULT_BASH_TIMEOUT <= 120
def test_bash_rejects_empty_command_instead_of_reporting_success(monkeypatch):
from src.agent_tools import subprocess_tools
async def fail_spawn(*_args, **_kwargs):
raise AssertionError("an empty command must never start a subprocess")
monkeypatch.setattr(asyncio, "create_subprocess_shell", fail_spawn)
result = asyncio.run(subprocess_tools.BashTool().execute({}, {}))
assert result["exit_code"] == 1
assert "command is required" in result["error"]
def test_bash_rejects_unicode_ffmpeg_drawtext_without_explicit_font(monkeypatch):
from src.agent_tools import subprocess_tools
async def fail_spawn(*_args, **_kwargs):
raise AssertionError("an unsafe drawtext command must never start a subprocess")
monkeypatch.setattr(asyncio, "create_subprocess_shell", fail_spawn)
monkeypatch.setattr(
subprocess_tools,
"_resolve_fontfile_for_text",
lambda _text: "/home/user/.local/share/fonts/NotoSansCJK-Regular.ttc",
)
result = asyncio.run(subprocess_tools.BashTool().execute(
"ffmpeg -i in.mp4 -vf \"drawtext=text='你好':x=10:y=10\" out.mp4",
{},
))
assert result["exit_code"] == 1
assert "fontfile" in result["error"]
assert "fc-match" in result["error"]
assert "/home/user/.local/share/fonts/NotoSansCJK-Regular.ttc" in result["error"]
def test_bash_allows_unicode_ffmpeg_drawtext_with_explicit_fontfile(monkeypatch, tmp_path):
from src.agent_tools import subprocess_tools
from src import tool_execution
captured = {}
sentinel = object()
async def fake_create(command, **kwargs):
captured["command"] = command
return sentinel
async def fake_stream(proc, **_kwargs):
assert proc is sentinel
return "ok", "", 0, False
monkeypatch.setattr(asyncio, "create_subprocess_shell", fake_create)
monkeypatch.setattr(subprocess_tools, "_run_subprocess_streaming", fake_stream)
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(tmp_path))
command = (
"ffmpeg -i in.mp4 -vf \"drawtext=fontfile=/fonts/NotoSansCJK.ttc:"
"text='你好':x=10:y=10\" out.mp4"
)
result = asyncio.run(subprocess_tools.BashTool().execute(command, {}))
assert result["exit_code"] == 0
assert "drawtext" in captured["command"]
+52
View File
@@ -0,0 +1,52 @@
from src.agent_loop import _classify_agent_request, _is_action_continuation
LIVE_TUI_PROMPT = (
"Create probe.txt containing exactly ODYSSEUS_BRIDGE_OK, then verify the "
"file contains exactly that text. Do not change any other file."
)
def test_self_contained_file_sequence_does_not_inherit_prior_context() -> None:
messages = [
{"role": "system", "content": "You are an assistant."},
{"role": "user", "content": "Search my email for an invoice."},
{"role": "assistant", "content": "I found the invoice."},
{"role": "user", "content": LIVE_TUI_PROMPT},
]
intent = _classify_agent_request(messages, LIVE_TUI_PROMPT)
assert _is_action_continuation(LIVE_TUI_PROMPT) is True
assert intent["continuation"] is False
assert intent["retrieval_query"] == LIVE_TUI_PROMPT
assert intent["domains"] == {"files"}
def test_short_action_reference_remains_a_continuation() -> None:
messages = [
{"role": "user", "content": "The obsolete file is tmp.txt."},
{"role": "assistant", "content": "Should I remove it?"},
{"role": "user", "content": "delete that file"},
]
intent = _classify_agent_request(messages, "delete that file")
assert intent["continuation"] is True
assert "The obsolete file is tmp.txt." in intent["retrieval_query"]
def test_current_state_is_not_mistaken_for_a_web_lookup() -> None:
prompt = "Inspect the current state."
intent = _classify_agent_request([{"role": "user", "content": prompt}], prompt)
assert "web" not in intent["domains"]
def test_current_release_remains_a_fresh_web_lookup() -> None:
prompt = "What is the current Kubernetes release?"
intent = _classify_agent_request([{"role": "user", "content": prompt}], prompt)
assert "web" in intent["domains"]
+761
View File
@@ -0,0 +1,761 @@
from src.agent_evidence import (
CompletionRequirements,
CompletionStatus,
EvidenceKind,
EvidenceLedger,
infer_completion_requirements,
requirements_from_runtime_context,
)
def test_infers_only_explicit_output_or_edit_paths():
requirements = infer_completion_requirements(
"Inspect evidence.png, then write answer.json and verify it."
)
assert requirements.required_artifacts == ("answer.json",)
assert requirements.verifier_required is True
def test_inference_ignores_prose_abbreviations_that_look_like_paths():
requirements = infer_completion_requirements(
"Generate statistics, e.g. token counts and timing totals."
)
assert requirements.required_artifacts == ()
def test_inference_recognizes_named_output_file():
requirements = infer_completion_requirements(
"Put the implementation in a file called /workspace/worker.py."
)
assert requirements.required_artifacts == ("/workspace/worker.py",)
def test_output_directory_outranks_relative_example_filenames():
requirements = infer_completion_requirements(
"Save the results into `/tmp_workspace/results`. Save each recovered table "
"as a separate file named `1.tex`, `2.tex`, `3.tex`, ..."
)
assert requirements.required_artifacts == ("/tmp_workspace/results",)
def test_inference_recognizes_localized_output_directory():
requirements = infer_completion_requirements(
"创建 `/tmp_workspace/results/` 目录,并在该目录下创建五个分类子目录。"
)
assert requirements.required_artifacts == ("/tmp_workspace/results",)
def test_inference_continues_past_example_to_explicit_output_file():
requirements = infer_completion_requirements(
'Write an integer (e.g. "1000000") to the file /app/answer.txt.'
)
assert requirements.required_artifacts == ("/app/answer.txt",)
def test_inference_recognizes_localized_output_request():
requirements = infer_completion_requirements(
"结果保存为.md文件,保存到 `/tmp_workspace/results/results.md`"
)
assert requirements.required_artifacts == ("/tmp_workspace/results/results.md",)
def test_runtime_requirements_override_instruction_inference():
requirements = requirements_from_runtime_context(
{
"completion_requirements": {
"required_artifacts": ["/workspace/output.html"],
"verifier_required": False,
"executable_verifier_available": True,
"verifier_commands": ["./test.sh"],
}
},
instruction="write ignored.json",
)
assert requirements.required_artifacts == ("/workspace/output.html",)
assert requirements.executable_verifier_available is True
assert requirements.verifier_commands == ("./test.sh",)
def test_available_verifier_is_required_and_declared_command_is_evidence():
requirements = infer_completion_requirements(
"Update app.py",
executable_verifier_available=True,
verifier_commands=("./test.sh",),
)
ledger = EvidenceLedger.from_tool_events(
[
{
"round": 1,
"tool": "edit_file",
"command": '{"path":"app.py","old_string":"a","new_string":"b"}',
"output": "edited app.py",
"exit_code": 0,
},
{
"round": 2,
"tool": "bash",
"command": "./test.sh",
"output": "ok",
"exit_code": 0,
},
],
requirements,
)
assert requirements.verifier_required is True
assert ledger.evaluate().status == CompletionStatus.VERIFIED
def test_successful_write_satisfies_declared_artifact_without_claiming_verification():
requirements = infer_completion_requirements("Write answer.json")
ledger = EvidenceLedger.from_tool_events(
[
{
"round": 1,
"tool": "write_file",
"command": "answer.json\n{\"ok\": true}",
"output": "wrote answer.json",
"exit_code": 0,
}
],
requirements,
)
decision = ledger.evaluate()
assert decision.status == CompletionStatus.SATISFIED
assert decision.can_complete is True
assert len(decision.evidence_ids) == 1
def test_private_browser_screenshot_satisfies_declared_artifact():
requirements = infer_completion_requirements(
"Render the generated HTML as an image and save as /workspace/output.png."
)
ledger = EvidenceLedger.from_tool_events(
[
{
"round": 2,
"tool": "private_browser",
"command": '{"action":"screenshot","path":"/workspace/output.png"}',
"output": "Screenshot saved",
"exit_code": 0,
}
],
requirements,
)
decision = ledger.evaluate()
assert decision.status == CompletionStatus.SATISFIED
assert decision.can_complete is True
def test_failed_retry_does_not_erase_successful_artifact_mutation():
requirements = infer_completion_requirements("Write answer.json")
ledger = EvidenceLedger.from_tool_events(
[
{
"round": 1,
"tool": "write_file",
"command": "answer.json\n{\"ok\": true}",
"output": "wrote answer.json",
"exit_code": 0,
},
{
"round": 2,
"tool": "write_file",
"command": "answer.json\ninvalid retry",
"error": "write failed",
"exit_code": 1,
},
],
requirements,
)
decision = ledger.evaluate()
assert decision.status == CompletionStatus.SATISFIED
assert decision.can_complete is True
assert len(decision.evidence_ids) == 1
def test_missing_declared_artifact_blocks_completion():
requirements = infer_completion_requirements("Write answer.json")
ledger = EvidenceLedger.from_tool_events([], requirements)
decision = ledger.evaluate()
assert decision.status == CompletionStatus.BLOCKED
assert decision.missing_artifacts == ("answer.json",)
def test_trailing_slash_output_directory_outranks_template_tex_filename():
instruction = """
You must create the following outputs under `/tmp_workspace/results/`:
- `2022.tsv`
- one or more paper source `.tex` files named `{title}_v1.tex`
"""
requirements = infer_completion_requirements(instruction)
assert requirements.required_artifacts == ("/tmp_workspace/results",)
def test_python_directory_copy_and_symlink_operations_are_artifact_mutations():
requirements = infer_completion_requirements(
"Create outputs under /tmp_workspace/results/"
)
ledger = EvidenceLedger.from_tool_events(
[{
"round": 1,
"tool": "python",
"command": (
"from pathlib import Path\n"
"import shutil\n"
"target = Path('/tmp_workspace/results')\n"
"target.mkdir(parents=True, exist_ok=True)\n"
"shutil.copy2('/tmp_workspace/0.png', target / '0.png')\n"
),
"output": "copied",
"exit_code": 0,
}],
requirements,
)
decision = ledger.evaluate()
assert decision.can_complete is True
assert decision.status == CompletionStatus.SATISFIED
def test_python_symlink_to_is_artifact_mutation():
requirements = infer_completion_requirements(
"Create outputs under /tmp_workspace/results/"
)
ledger = EvidenceLedger.from_tool_events(
[{
"round": 1,
"tool": "python",
"command": (
"from pathlib import Path\n"
"target = Path('/tmp_workspace/results')\n"
"target.mkdir(parents=True, exist_ok=True)\n"
"(target / '0.png').symlink_to('/tmp_workspace/0.png')\n"
),
"output": "linked",
"exit_code": 0,
}],
requirements,
)
assert ledger.evaluate().can_complete is True
def test_reading_preexisting_artifact_does_not_satisfy_mutation_request():
requirements = infer_completion_requirements("Update app.py")
ledger = EvidenceLedger.from_tool_events(
[{
"round": 1,
"tool": "bash",
"command": "cat app.py",
"output": "VALUE = 1",
"exit_code": 0,
}],
requirements,
)
decision = ledger.evaluate()
assert decision.status == CompletionStatus.BLOCKED
assert decision.missing_artifacts == ("app.py",)
def test_latest_authoritative_verifier_result_wins():
requirements = infer_completion_requirements("Update app.py, then run pytest")
ledger = EvidenceLedger.from_tool_events(
[
{
"round": 1,
"tool": "edit_file",
"command": '{"path":"app.py","old_string":"a","new_string":"b"}',
"output": "edited app.py",
"exit_code": 0,
},
{
"round": 2,
"tool": "bash",
"command": "pytest -q",
"output": "1 failed",
"exit_code": 1,
},
],
requirements,
)
assert ledger.evaluate().status == CompletionStatus.FAILED
ledger.record_tool_event(
{
"round": 3,
"tool": "bash",
"command": "pytest -q",
"output": "1 passed",
"exit_code": 0,
}
)
decision = ledger.evaluate()
assert decision.status == CompletionStatus.VERIFIED
assert decision.can_complete is True
def test_passing_verifier_before_latest_mutation_is_stale():
requirements = infer_completion_requirements(
"Update app.py",
executable_verifier_available=True,
verifier_commands=("./test.sh",),
)
ledger = EvidenceLedger.from_tool_events(
[
{
"round": 1,
"tool": "bash",
"command": "./test.sh",
"output": "ok",
"exit_code": 0,
},
{
"round": 2,
"tool": "edit_file",
"command": '{"path":"app.py","old_string":"a","new_string":"b"}',
"output": "edited app.py",
"exit_code": 0,
},
],
requirements,
)
decision = ledger.evaluate()
assert decision.status == CompletionStatus.BLOCKED
assert "predates" in decision.reason
def test_successful_non_verification_shell_command_is_not_verifier_evidence():
requirements = infer_completion_requirements("Update app.py, then verify it")
ledger = EvidenceLedger.from_tool_events(
[
{
"round": 1,
"tool": "edit_file",
"command": '{"path":"app.py","old_string":"a","new_string":"b"}',
"output": "edited app.py",
"exit_code": 0,
},
{
"round": 2,
"tool": "bash",
"command": "pwd",
"output": "/workspace",
"exit_code": 0,
},
],
requirements,
)
decision = ledger.evaluate()
assert decision.status == CompletionStatus.BLOCKED
assert "no current artifact validation" in decision.reason
def test_artifact_inspection_satisfies_natural_language_verification_request():
requirements = infer_completion_requirements("Write answer.json, then verify it")
ledger = EvidenceLedger.from_tool_events(
[
{
"round": 1,
"tool": "write_file",
"command": "answer.json\n{}",
"output": "wrote answer.json",
"exit_code": 0,
},
{
"round": 2,
"tool": "bash",
"command": "cat answer.json",
"output": "{}",
"exit_code": 0,
},
],
requirements,
)
decision = ledger.evaluate()
assert decision.status == CompletionStatus.SATISFIED
assert decision.can_complete is True
def test_shell_redirect_write_is_not_artifact_validation():
requirements = infer_completion_requirements("Write answer.json")
ledger = EvidenceLedger.from_tool_events(
[{
"round": 1,
"tool": "bash",
"command": "cat > answer.json <<'EOF'\n{}\nEOF",
"output": "",
"exit_code": 0,
}],
requirements,
)
assert ledger.evaluate().can_complete is True
assert not any(
event.kind == EvidenceKind.ARTIFACT_VALIDATION
for event in ledger.events
)
def test_compiler_check_must_be_repeated_after_artifact_rewrite():
requirements = infer_completion_requirements("Update proof.v")
events = [
{
"round": 1,
"tool": "write_file",
"command": "proof.v\nfirst version",
"output": "wrote proof.v",
"exit_code": 0,
},
{
"round": 2,
"tool": "bash",
"command": "coqc proof.v",
"output": "compile error",
"exit_code": 1,
},
{
"round": 3,
"tool": "write_file",
"command": "proof.v\nsecond version",
"output": "wrote proof.v",
"exit_code": 0,
},
]
ledger = EvidenceLedger.from_tool_events(events, requirements)
decision = ledger.evaluate()
assert decision.can_complete is False
assert "validation predates" in decision.reason
ledger.record_tool_event({
"round": 4,
"tool": "bash",
"command": "coqc proof.v",
"output": "",
"exit_code": 0,
})
decision = ledger.evaluate()
assert decision.can_complete is True
assert "validation evidence" in decision.reason
def test_python_artifact_write_is_detected_against_declared_path():
requirements = infer_completion_requirements("Write answer.json")
ledger = EvidenceLedger.from_tool_events(
[
{
"round": 1,
"tool": "python",
"command": 'open("/workspace/answer.json", "w").write("{}")',
"output": "",
"exit_code": 0,
}
],
requirements,
)
assert ledger.evaluate().status == CompletionStatus.SATISFIED
assert any(event.kind == EvidenceKind.ARTIFACT_MUTATION for event in ledger.events)
def test_inspect_media_export_satisfies_declared_artifact():
requirements = infer_completion_requirements("Save /workspace/frame.png")
ledger = EvidenceLedger.from_tool_events(
[{
"round": 1,
"tool": "inspect_media",
"command": '{"path":"/workspace/video.mp4","timestamp":"5",'
'"output_path":"/workspace/frame.png"}',
"output": "Created still image",
"exit_code": 0,
}],
requirements,
)
assert ledger.evaluate().status == CompletionStatus.SATISFIED
def test_inspect_media_batch_exports_satisfy_declared_artifacts():
requirements = CompletionRequirements(required_artifacts=(
"/workspace/one.png", "/workspace/two.png"
))
ledger = EvidenceLedger.from_tool_events(
[{
"round": 1,
"tool": "inspect_media",
"command": '{"path":"/workspace/video.mp4","exports":['
'{"timestamp":"1","output_path":"/workspace/one.png"},'
'{"timestamp":"2","output_path":"/workspace/two.png"}]}',
"output": "Created still images",
"exit_code": 0,
}],
requirements,
)
assert ledger.evaluate().status == CompletionStatus.SATISFIED
def test_python_image_save_satisfies_declared_artifact():
requirements = infer_completion_requirements("Save /workspace/poster.png")
ledger = EvidenceLedger.from_tool_events(
[{
"round": 1,
"tool": "python",
"command": 'poster.save("/workspace/poster.png")',
"output": "saved",
"exit_code": 0,
}],
requirements,
)
assert ledger.evaluate().status == CompletionStatus.SATISFIED
def test_python_dataframe_export_satisfies_declared_artifact():
requirements = infer_completion_requirements("Export /workspace/results.csv")
ledger = EvidenceLedger.from_tool_events(
[{
"round": 1,
"tool": "python",
"command": 'frame.to_csv("/workspace/results.csv", index=False)',
"output": "",
"exit_code": 0,
}],
requirements,
)
assert ledger.evaluate().status == CompletionStatus.SATISFIED
def test_latest_failed_mutation_invalidates_earlier_success_for_required_artifact():
requirements = CompletionRequirements(
required_artifacts=("/workspace/results.jsonl",)
)
ledger = EvidenceLedger.from_tool_events(
[
{
"round": 1,
"tool": "write_file",
"command": "/workspace/results.jsonl\n{\"value\": 1}\n",
"output": "wrote results",
"exit_code": 0,
},
{
"round": 2,
"tool": "python",
"command": (
"with open('/workspace/results.jsonl', 'w') as f:\n"
" raise RuntimeError('conversion failed')"
),
"output": "RuntimeError: conversion failed",
"exit_code": 1,
},
],
requirements,
)
decision = ledger.evaluate()
assert decision.status == CompletionStatus.BLOCKED
assert decision.missing_artifacts == ("/workspace/results.jsonl",)
def test_write_file_helper_script_does_not_satisfy_artifact_it_mentions():
requirements = CompletionRequirements(
required_artifacts=(
"/workspace/meta_analysis.csv",
"/workspace/cross_benchmark_comparison.png",
)
)
ledger = EvidenceLedger.from_tool_events(
[{
"round": 1,
"tool": "write_file",
"command": (
"/workspace/download_and_analyze.py\n"
"import pandas as pd\n"
"pd.DataFrame().to_csv('/workspace/meta_analysis.csv')\n"
),
"output": "wrote helper script",
"exit_code": 0,
}],
requirements,
)
decision = ledger.evaluate()
assert decision.status == CompletionStatus.BLOCKED
assert decision.missing_artifacts == (
"/workspace/meta_analysis.csv",
"/workspace/cross_benchmark_comparison.png",
)
def test_absolute_required_artifact_requires_exact_path_not_same_basename():
requirements = CompletionRequirements(
required_artifacts=("/workspace/meta_analysis.csv",)
)
ledger = EvidenceLedger.from_tool_events(
[{
"round": 1,
"tool": "write_file",
"command": (
"/workspace/papers/meta_analysis.csv\n"
"Model,RefCOCO-avg,ERQA\n"
),
"output": "wrote nested csv",
"exit_code": 0,
}],
requirements,
)
decision = ledger.evaluate()
assert decision.status == CompletionStatus.BLOCKED
assert decision.missing_artifacts == ("/workspace/meta_analysis.csv",)
def test_sed_in_place_edit_is_recorded_as_artifact_mutation():
requirements = infer_completion_requirements("Update /workspace/app.py")
ledger = EvidenceLedger.from_tool_events(
[{
"round": 1,
"tool": "bash",
"command": "sed -i 's/VALUE = 1/VALUE = 2/' /workspace/app.py",
"output": "",
"exit_code": 0,
}],
requirements,
)
assert any(
event.kind == EvidenceKind.ARTIFACT_MUTATION
and event.artifact_path == "/workspace/app.py"
for event in ledger.events
)
def test_evidence_records_hashes_not_raw_command_or_output():
secret = "super-secret-value"
ledger = EvidenceLedger.from_tool_events(
[
{
"round": 1,
"tool": "bash",
"command": f"echo {secret}",
"output": secret,
"exit_code": 0,
}
]
)
serialized = str(ledger.to_list())
assert secret not in serialized
assert len(ledger.events[0].command_sha256) == 64
assert len(ledger.events[0].output_sha256) == 64
def test_successful_tool_event_does_not_complete_when_workspace_artifact_is_missing(tmp_path):
requirements = CompletionRequirements(
required_artifacts=("/workspace/output.txt",),
workspace_root=str(tmp_path),
)
ledger = EvidenceLedger.from_tool_events(
[{
"round": 1,
"tool": "write_file",
"command": "/workspace/output.txt",
"output": "wrote 12 bytes",
"exit_code": 0,
}],
requirements,
)
decision = ledger.evaluate()
assert decision.status == CompletionStatus.BLOCKED
assert decision.missing_artifacts == ("/workspace/output.txt",)
def test_workspace_artifact_must_not_be_empty(tmp_path):
output = tmp_path / "output.txt"
output.touch()
requirements = CompletionRequirements(
required_artifacts=("/workspace/output.txt",),
workspace_root=str(tmp_path),
)
ledger = EvidenceLedger.from_tool_events(
[{
"round": 1,
"tool": "write_file",
"command": "/workspace/output.txt",
"output": "wrote 0 bytes",
"exit_code": 0,
}],
requirements,
)
assert ledger.evaluate().status == CompletionStatus.BLOCKED
def test_workspace_image_artifact_must_have_valid_file_signature(tmp_path):
(tmp_path / "score_chart.png").write_text("placeholder")
requirements = CompletionRequirements(
required_artifacts=("/workspace/score_chart.png",),
workspace_root=str(tmp_path),
)
ledger = EvidenceLedger.from_tool_events(
[{
"round": 1,
"tool": "write_file",
"command": "/workspace/score_chart.png",
"output": "wrote 11 bytes",
"exit_code": 0,
}],
requirements,
)
decision = ledger.evaluate()
assert decision.status == CompletionStatus.BLOCKED
assert decision.missing_artifacts == ("/workspace/score_chart.png",)
def test_workspace_artifact_presence_completes_only_after_successful_write(tmp_path):
(tmp_path / "output.txt").write_text("done\n")
requirements = CompletionRequirements(
required_artifacts=("/workspace/output.txt",),
workspace_root=str(tmp_path),
)
ledger = EvidenceLedger.from_tool_events(
[{
"round": 1,
"tool": "write_file",
"command": "/workspace/output.txt",
"output": "wrote 5 bytes",
"exit_code": 0,
}],
requirements,
)
assert ledger.evaluate().can_complete is True
def test_exhaustion_and_awaiting_user_are_non_completion_states():
ledger = EvidenceLedger()
assert ledger.evaluate(exhausted=True).status == CompletionStatus.EXHAUSTED
assert ledger.evaluate(awaiting_user=True).status == CompletionStatus.AWAITING_USER
+798
View File
@@ -0,0 +1,798 @@
import asyncio
import json
import src.agent_loop as agent_loop
from src.tool_parsing import ToolBlock
from src.tool_capabilities import ToolGateDecision
def _events(chunks):
parsed = []
for chunk in chunks:
if not chunk.startswith("data: ") or chunk.startswith("data: [DONE]"):
continue
parsed.append(json.loads(chunk[6:]))
return parsed
def _patch_loop(monkeypatch, responses, captured_kwargs=None):
monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
monkeypatch.setattr(agent_loop, "blocked_tools_for_owner", lambda owner: set())
monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
monkeypatch.setattr(agent_loop, "tool_result_should_arm_gate", lambda *args, **kwargs: False)
monkeypatch.setattr(
agent_loop.ToolRunSecurityContext,
"decision_for",
lambda self, *args, **kwargs: ToolGateDecision(True),
)
async def execute(block, *args, **kwargs):
return block.tool_type, {
"output": f"executed {block.tool_type}",
"exit_code": 0,
}
call_index = 0
async def stream(_candidates, messages, **kwargs):
nonlocal call_index
if captured_kwargs is not None:
captured_kwargs.append(kwargs)
response = responses[min(call_index, len(responses) - 1)]
call_index += 1
yield f'data: {json.dumps({"delta": response})}\n\n'
yield "data: [DONE]\n\n"
monkeypatch.setattr(agent_loop, "execute_tool_block", execute)
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", stream)
return lambda: call_index
def _run(instruction, *, max_rounds=4, relevant_tools=None, runtime_context=None):
async def collect():
return [
chunk
async for chunk in agent_loop.stream_agent_loop(
"http://unused.test/v1",
"test-model",
[{"role": "user", "content": instruction}],
max_rounds=max_rounds,
relevant_tools=relevant_tools or {"write_file"},
owner="pewds",
client_runtime_context=runtime_context or {"terminal_agent": True},
)
]
return _events(asyncio.run(collect()))
def test_failed_workspace_mutation_attempts_are_not_hidden_by_successful_probe():
failed = ToolBlock("python", 'open("/workspace/answer.png", "wb")')
probe = ToolBlock("inspect_media", '{"path":"/workspace/input.mp4"}')
records = [
{"tool_name": "python", "result": {"error": "conversion failed", "exit_code": 1}},
{"tool_name": "inspect_media", "result": {"output": "evidence", "exit_code": 0}},
]
assert agent_loop._failed_workspace_mutation_attempts([failed, probe], records) == 1
def test_terminal_completion_repairs_missing_artifact_at_most_twice(monkeypatch):
calls = _patch_loop(monkeypatch, ["Done without writing anything."])
events = _run("Write answer.json", max_rounds=4)
blocked = [event for event in events if event.get("type") == "completion_blocked"]
assert [event["attempt"] for event in blocked] == [1, 2]
assert calls() == 3
decision = next(event["data"] for event in events if event.get("type") == "completion_decision")
assert decision["status"] == "blocked"
assert decision["missing_artifacts"] == ["answer.json"]
def test_failed_trailing_tool_with_planning_prose_continues_artifact_task(monkeypatch):
calls = _patch_loop(
monkeypatch,
[
"I found the source and will inspect it now.\n```bash\nfalse\n```",
"Done without creating the required artifact.",
],
)
async def fail_execute(block, *args, **kwargs):
return block.tool_type, {
"output": "no matches",
"exit_code": 1,
}
monkeypatch.setattr(agent_loop, "execute_tool_block", fail_execute)
events = _run(
"Create answer.json after inspecting the source",
max_rounds=3,
relevant_tools={"bash", "write_file"},
)
assert calls() > 1
assert any(
event.get("type") == "completion_blocked"
and event.get("decision", {}).get("missing_artifacts") == ["answer.json"]
for event in events
)
def test_exact_failed_call_is_blocked_across_planning_and_intervening_failure(monkeypatch):
repeated = "python3 /tmp_workspace/classify_and_copy.py"
executed = []
_patch_loop(
monkeypatch,
[
f'I will run the classifier now.\n<tool_call><invoke name="bash"><parameter name="command">{repeated}</parameter></invoke></tool_call>',
"I will repair the variable first.\n<tool_call><invoke name=\"bash\"><parameter name=\"command\">python3 -c 'print(classifications)'</parameter></invoke></tool_call>",
f'I will retry the classifier.\n<tool_call><invoke name="bash"><parameter name="command">{repeated}</parameter></invoke></tool_call>',
"The work is complete.",
],
)
async def fail_execute(block, *args, **kwargs):
executed.append(block.content)
return block.tool_type, {"output": f"failed: {block.content}", "exit_code": 1}
monkeypatch.setattr(agent_loop, "execute_tool_block", fail_execute)
events = _run(
"Create /tmp_workspace/results after classifying the files",
max_rounds=4,
relevant_tools={"bash", "write_file"},
runtime_context={
"terminal_agent": True,
"completion_requirements": {
"required_artifacts": ["/tmp_workspace/results"],
},
},
)
assert executed.count(repeated) == 1, events
blocked = [event for event in events if event.get("type") == "tool_retry_blocked"]
assert len(blocked) == 1
assert blocked[0]["previous_round"] == 1
assert blocked[0]["command"] == repeated
def test_exact_failed_call_can_retry_after_successful_workspace_mutation(monkeypatch):
repeated = "python3 /tmp_workspace/classify_and_copy.py"
executed = []
_patch_loop(
monkeypatch,
[
f'<tool_call><invoke name="bash"><parameter name="command">{repeated}</parameter></invoke></tool_call>',
'<tool_call><invoke name="write_file"><parameter name="path">/tmp_workspace/repair.py</parameter><parameter name="content">fixed = True</parameter></invoke></tool_call>',
f'<tool_call><invoke name="bash"><parameter name="command">{repeated}</parameter></invoke></tool_call>',
"Repair attempted.",
],
)
async def execute(block, *args, **kwargs):
executed.append((block.tool_type, block.content))
if block.tool_type == "write_file":
return block.tool_type, {"output": "written", "exit_code": 0}
return block.tool_type, {"output": "classifier failed", "exit_code": 1}
monkeypatch.setattr(agent_loop, "execute_tool_block", execute)
events = _run(
"Create /tmp_workspace/results after repairing and running the classifier",
max_rounds=4,
relevant_tools={"bash", "write_file"},
runtime_context={
"terminal_agent": True,
"completion_requirements": {
"required_artifacts": ["/tmp_workspace/results"],
},
},
)
assert sum(content == repeated for _, content in executed) == 2
assert not any(event.get("type") == "tool_retry_blocked" for event in events)
def test_terminal_artifact_task_repairs_after_consecutive_failed_batches(monkeypatch):
executed = []
_patch_loop(
monkeypatch,
[
'<tool_call><invoke name="python"><parameter name="code">print(missing_one)</parameter></invoke></tool_call>',
'<tool_call><invoke name="python"><parameter name="code">print(missing_two)</parameter></invoke></tool_call>',
'<tool_call><invoke name="python"><parameter name="code">print(missing_three)</parameter></invoke></tool_call>',
'<tool_call><invoke name="python"><parameter name="code">print(missing_four)</parameter></invoke></tool_call>',
'<tool_call><invoke name="python"><parameter name="code">print(missing_five)</parameter></invoke></tool_call>',
'<tool_call><invoke name="write_file"><parameter name="path">answer.json</parameter><parameter name="content">{\"ok\": true}</parameter></invoke></tool_call>',
"Created answer.json.",
],
)
async def execute(block, *args, **kwargs):
executed.append((block.tool_type, block.content))
if block.tool_type == "write_file":
return block.tool_type, {"output": "written", "exit_code": 0}
return block.tool_type, {
"error": f"NameError from {block.content}",
"exit_code": 1,
}
monkeypatch.setattr(agent_loop, "execute_tool_block", execute)
events = _run(
"Create answer.json and verify it",
max_rounds=7,
relevant_tools={"python", "write_file"},
runtime_context={
"terminal_agent": True,
"failed_tool_round_limit": 5,
"completion_requirements": {
"required_artifacts": ["answer.json"],
},
},
)
repairs = [event for event in events if event.get("type") == "artifact_repair_required"]
assert len(repairs) == 1
assert repairs[0]["attempt"] == 1
assert "missing_five" in repairs[0]["last_failure"]
assert not any(
event.get("type") == "loop_breaker_triggered"
and event.get("reason") == "consecutive_tool_failures"
for event in events
)
assert any(tool == "write_file" for tool, _ in executed)
decision = next(
event["data"]
for event in events
if event.get("type") == "completion_decision"
)
assert decision["can_complete"] is True
def test_varied_failed_artifact_mutations_have_cumulative_cap(monkeypatch):
executed = []
_patch_loop(
monkeypatch,
[
'<tool_call><invoke name="python"><parameter name="code">'
f'open("/workspace/answer.json", "w").write(missing_{index})'
'</parameter></invoke></tool_call>'
for index in range(1, 9)
],
)
async def execute(block, *args, **kwargs):
executed.append((block.tool_type, block.content))
return block.tool_type, {
"error": f"NameError from {block.content}",
"exit_code": 1,
}
monkeypatch.setattr(agent_loop, "execute_tool_block", execute)
events = _run(
"Create answer.json and verify it",
max_rounds=8,
relevant_tools={"python", "write_file"},
runtime_context={
"terminal_agent": True,
"failed_tool_round_limit": 99,
"completion_requirements": {
"required_artifacts": ["answer.json"],
},
},
)
guard = next(
event for event in events
if event.get("type") == "loop_breaker_triggered"
and event.get("reason") == "cumulative_artifact_mutation_failures"
)
assert guard["failed_batches"] == 6
assert len(executed) == 6
def test_exact_successful_read_is_blocked_until_workspace_changes(monkeypatch):
inspection = "find /tmp_workspace/results -type f | wc -l"
executed = []
_patch_loop(
monkeypatch,
[
f'I found partial output and will count it.\n<tool_call><invoke name="bash"><parameter name="command">{inspection}</parameter></invoke></tool_call>',
f'I should check the count once more.\n<tool_call><invoke name="bash"><parameter name="command">{inspection}</parameter></invoke></tool_call>',
'<tool_call><invoke name="write_file"><parameter name="path">answer.json</parameter><parameter name="content">{\"ok\": true}</parameter></invoke></tool_call>',
"Created answer.json.",
],
)
async def execute(block, *args, **kwargs):
executed.append((block.tool_type, block.content))
if block.tool_type == "write_file":
return block.tool_type, {"output": "written", "exit_code": 0}
return block.tool_type, {"output": "7", "exit_code": 0}
monkeypatch.setattr(agent_loop, "execute_tool_block", execute)
events = _run(
"Create answer.json from the inspected workspace",
max_rounds=4,
relevant_tools={"bash", "write_file"},
runtime_context={
"terminal_agent": True,
"completion_requirements": {"required_artifacts": ["answer.json"]},
},
)
assert sum(content == inspection for _, content in executed) == 1
blocked = [
event for event in events
if event.get("type") == "tool_retry_blocked"
and event.get("reason") == "repeated_read_only_call"
]
assert len(blocked) == 1
assert blocked[0]["previous_round"] == 1
assert any(tool == "write_file" for tool, _ in executed)
def test_terminal_completion_recovers_fenced_body_after_two_repairs(monkeypatch):
executed = []
calls = _patch_loop(
monkeypatch,
[
"Done without writing anything.",
"Still done without writing anything.",
'```json\n{"ok": true}\n```',
"Created answer.json.",
],
)
original_execute = agent_loop.execute_tool_block
async def record_execute(block, *args, **kwargs):
executed.append(block)
return await original_execute(block, *args, **kwargs)
monkeypatch.setattr(agent_loop, "execute_tool_block", record_execute)
events = _run("Write answer.json", max_rounds=5)
assert [(block.tool_type, block.content) for block in executed] == [
("write_file", 'answer.json\n{"ok": true}'),
]
decision = next(
event["data"]
for event in events
if event.get("type") == "completion_decision"
)
assert decision["status"] == "satisfied"
assert decision["can_complete"] is True
def test_successful_artifact_write_emits_satisfied_completion(monkeypatch):
calls = _patch_loop(
monkeypatch,
[
'```write_file\nanswer.json\n{"ok": true}\n```',
"Done. Created answer.json.",
],
)
events = _run("Write answer.json")
assert calls() == 2, events
assert not any(event.get("type") == "completion_blocked" for event in events)
decision = next(event["data"] for event in events if event.get("type") == "completion_decision")
assert decision["status"] == "satisfied"
assert decision["can_complete"] is True
metrics = next(event["data"] for event in events if event.get("type") == "metrics")
assert metrics["completion_decision"] == decision
assert metrics["completion_requirements"] == {
"required_artifacts": ["answer.json"],
"verifier_required": False,
"executable_verifier_available": False,
"verifier_commands": [],
"workspace_root": "",
}
assert any(
evidence["kind"] == "artifact_mutation"
for evidence in metrics["evidence_events"]
)
def test_current_artifact_inspection_requires_post_mutation_matching_path():
write = {
"tool": "write_file", "command": "/workspace/output.html\n<body/>",
"exit_code": 0,
}
source_inspection = {
"tool": "private_browser", "command": '{"url":"file:///workspace/source.html"}',
"exit_code": 0,
}
output_inspection = {
"tool": "private_browser", "command": '{"url":"file:///workspace/output.html"}',
}
assert not agent_loop._artifact_has_current_inspection(
[source_inspection, write], ["/workspace/output.html"]
)
assert agent_loop._artifact_has_current_inspection(
[write, output_inspection], ["/workspace/output.html"]
)
assert not agent_loop._artifact_has_current_inspection(
[write, output_inspection, write], ["/workspace/output.html"]
)
def test_verified_artifact_survives_provider_error_during_finish_round(monkeypatch):
calls = 0
_patch_loop(monkeypatch, [""])
async def stream(_candidates, messages, **kwargs):
nonlocal calls
calls += 1
if calls == 1:
tool_calls = [
{
"name": "write_file",
"arguments": json.dumps({
"path": "/workspace/output.html",
"content": "<main>complete</main>",
}),
},
{
"name": "private_browser",
"arguments": json.dumps({
"action": "open",
"url": "file:///workspace/output.html",
}),
},
]
yield f'data: {json.dumps({"type": "tool_calls", "calls": tool_calls})}\n\n'
yield "data: [DONE]\n\n"
return
yield 'event: error\ndata: {"status": 504, "error": "stream timeout"}\n\n'
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", stream)
events = _run(
"Create /workspace/output.html",
max_rounds=5,
relevant_tools={"write_file", "private_browser"},
runtime_context={
"terminal_agent": True,
"surface": "odysseus-native",
"completion_requirements": {
"required_artifacts": ["/workspace/output.html"],
},
},
)
assert calls == 2
assert not any(event.get("type") == "agent_terminal" for event in events)
final = next(event for event in events if event.get("type") == "final_response")
assert "output.html" in final["content"]
assert "verified" in final["content"].lower()
def test_uninspected_artifact_still_fails_on_provider_error(monkeypatch):
calls = 0
_patch_loop(monkeypatch, [""])
async def stream(_candidates, messages, **kwargs):
nonlocal calls
calls += 1
if calls == 1:
tool_call = {
"name": "write_file",
"arguments": json.dumps({
"path": "/workspace/answer.json",
"content": '{"status": "unverified"}',
}),
}
yield f'data: {json.dumps({"type": "tool_calls", "calls": [tool_call]})}\n\n'
yield "data: [DONE]\n\n"
return
yield 'event: error\ndata: {"status": 504, "error": "stream timeout"}\n\n'
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", stream)
events = _run(
"Create /workspace/answer.json",
max_rounds=4,
relevant_tools={"write_file"},
runtime_context={
"terminal_agent": True,
"surface": "odysseus-native",
"completion_requirements": {
"required_artifacts": ["/workspace/answer.json"],
},
},
)
assert calls == 2
terminal = next(
(event for event in events if event.get("type") == "agent_terminal"),
None,
)
assert terminal is not None, events
assert terminal["data"]["failed"] is True
assert terminal["data"]["failure"]["status"] == 504
def test_verified_artifact_gets_only_one_finish_nudge(monkeypatch):
_patch_loop(
monkeypatch,
[
'```write_file\noutput.html\n<body>done</body>\n```',
'<tool_call><invoke name="private_browser"><parameter name="action">open</parameter><parameter name="url">file:///workspace/output.html</parameter></invoke></tool_call>',
'<tool_call><invoke name="read_file"><parameter name="path">output.html</parameter></invoke></tool_call>',
"Done. Created and checked output.html.",
],
)
events = _run(
"Create output.html",
max_rounds=5,
relevant_tools={"write_file", "private_browser", "read_file"},
)
nudges = [event for event in events if event.get("type") == "artifact_finish_nudge"]
assert len(nudges) == 1
assert nudges[0]["reason"] == "artifact_complete_and_currently_inspected"
def test_verified_artifact_finish_round_preserves_correction_budget(monkeypatch):
captured = []
_patch_loop(
monkeypatch,
[
'```write_file\noutput.html\n<body>done</body>\n```',
'<tool_call><invoke name="private_browser"><parameter name="action">open</parameter><parameter name="url">file:///workspace/output.html</parameter></invoke></tool_call>',
"Done. Created and checked output.html.",
],
captured_kwargs=captured,
)
events = _run(
"Create output.html",
max_rounds=5,
relevant_tools={"write_file", "private_browser"},
)
assert any(event.get("type") == "artifact_finish_nudge" for event in events)
assert captured[-1]["candidate_request_factory"]
# Request kwargs are resolved lazily for each endpoint; exercise the
# primary factory exactly as the fallback streamer does.
request = asyncio.run(captured[-1]["candidate_request_factory"](0, "http://unused.test/v1", "test-model", {}))
# The first post-inspection response may need to rewrite the complete
# artifact. Only the response after that correction is verified is
# bounded to a short final answer.
assert request["kwargs"]["max_tokens"] > 2048
def test_finish_nudge_does_not_accept_unfinished_correction_promise(monkeypatch):
calls = _patch_loop(
monkeypatch,
[
'```write_file\n/workspace/output.html\n<body>draft</body>\n```',
'<tool_call><invoke name="private_browser"><parameter name="action">open</parameter><parameter name="url">file:///workspace/output.html</parameter></invoke></tool_call>',
"The preview revealed a defect. I should complete output.html by adding labels.",
'```write_file\n/workspace/output.html\n<body>corrected</body>\n```',
"Done. Corrected and checked output.html.",
],
)
events = _run(
"Create /workspace/output.html",
max_rounds=6,
relevant_tools={"write_file", "private_browser"},
runtime_context={
"terminal_agent": True,
"surface": "odysseus-native",
"completion_requirements": {
"required_artifacts": ["/workspace/output.html"],
},
},
)
assert calls() == 5, events
assert len([
event for event in events if event.get("type") == "artifact_finish_nudge"
]) == 1
assert len([
event for event in events
if event.get("type") == "artifact_finish_after_verified_correction"
]) == 1
decision = next(
event["data"] for event in events
if event.get("type") == "completion_decision"
)
assert decision["can_complete"] is True
def test_discovered_verifier_runs_in_same_batch_after_mutation(monkeypatch):
calls = _patch_loop(
monkeypatch,
[
'```write_file\nanswer.json\n{"ok": true}\n```',
"Done without running the task check.",
"Done again without selecting a tool.",
],
)
events = _run(
"Write answer.json",
max_rounds=6,
relevant_tools={"write_file", "bash"},
runtime_context={
"terminal_agent": True,
"completion_requirements": {
"required_artifacts": ["answer.json"],
"verifier_required": True,
"executable_verifier_available": True,
"verifier_commands": ["./test.sh"],
},
},
)
blocked = [event for event in events if event.get("type") == "completion_blocked"]
assert blocked == []
decision = next(event["data"] for event in events if event.get("type") == "completion_decision")
assert decision["status"] == "verified"
assert calls() == 1
assert any(
event.get("type") == "tool_start"
and event.get("tool") == "bash"
and event.get("command") == "./test.sh"
for event in events
)
def test_terminal_shell_wrapped_write_file_is_recovered_without_shell_execution():
recovered = agent_loop._recover_shell_wrapped_file_tool(
ToolBlock("bash", 'write_file /workspace/app.py "VALUE = 2"')
)
assert recovered.tool_type == "write_file"
assert recovered.content == "/workspace/app.py\nVALUE = 2"
def test_terminal_multiline_shell_wrapped_write_file_is_recovered():
recovered = agent_loop._recover_shell_wrapped_file_tool(
ToolBlock("bash", "write_file\n/workspace/app.py\nVALUE = '<ok>'\n")
)
assert recovered.tool_type == "write_file"
assert recovered.content == "/workspace/app.py\nVALUE = '<ok>'"
def test_terminal_shell_wrapped_edit_file_json_is_recovered_with_literal_operators():
recovered = agent_loop._recover_shell_wrapped_file_tool(
ToolBlock(
"bash",
'edit_file {"path": "/workspace/app.py", "old_string": "x >> y", '
'"new_string": "x < y"}',
)
)
assert recovered.tool_type == "edit_file"
assert json.loads(recovered.content) == {
"path": "/workspace/app.py",
"old_string": "x >> y",
"new_string": "x < y",
"replace_all": False,
}
def test_ambiguous_shell_wrapped_file_tool_is_not_recovered():
original = ToolBlock("bash", "write_file app.py value && ./test.sh")
assert agent_loop._recover_shell_wrapped_file_tool(original) == original
def test_terminal_adjacent_fenced_write_body_is_recovered_for_required_artifact():
recovered = agent_loop._recover_adjacent_fenced_write_file(
"""```bash
write_file /workspace/report.md
```
```markdown
# Report
Verified result.
```""",
["/workspace/report.md"],
)
assert recovered == ToolBlock(
"write_file",
"/workspace/report.md\n# Report\n\nVerified result.",
)
def test_terminal_fenced_body_before_name_only_write_uses_single_required_artifact():
recovered = agent_loop._recover_adjacent_fenced_write_file(
"""```html
<main>Complete output</main>
```
```bash
write_file
```""",
["/workspace/output.html"],
)
assert recovered == ToolBlock(
"write_file",
"/workspace/output.html\n<main>Complete output</main>",
)
def test_terminal_adjacent_fenced_write_rejects_unrequired_path():
recovered = agent_loop._recover_adjacent_fenced_write_file(
"""```bash
write_file /workspace/unrequested.md
```
```text
content
```""",
["/workspace/report.md"],
)
assert recovered is None
def test_terminal_recovers_unlabeled_ffmpeg_fence_for_required_media_artifact():
recovered = agent_loop._recover_fenced_media_shell_command(
"""I will create the requested video now.
```
ffmpeg -y -i /workspace/in.mp4 -vf scale=640:360 /workspace/merged.mp4
```""",
["/workspace/merged.mp4"],
)
assert recovered == ToolBlock(
"bash",
"ffmpeg -y -i /workspace/in.mp4 -vf scale=640:360 /workspace/merged.mp4",
)
def test_terminal_unlabeled_shell_fence_must_target_required_media_artifact():
assert agent_loop._recover_fenced_media_shell_command(
"""```
ffmpeg -y -i /workspace/in.mp4 /workspace/other.mp4
```""",
["/workspace/merged.mp4"],
) is None
def test_terminal_unlabeled_non_media_command_remains_inert():
assert agent_loop._recover_fenced_media_shell_command(
"""```
python -c 'print(1)'
```""",
["/workspace/result.txt"],
) is None
def test_explicit_run_the_named_script_is_a_verification_command():
request = (
"Write a Python script named /workspace/calculate_ratio.py that loads "
"the JSON and saves its result. Then run the script."
)
assert agent_loop._requested_verification_command(request) == (
"python /workspace/calculate_ratio.py"
)
assert agent_loop._requested_post_edit_verification(request)
def test_run_the_script_is_not_inferred_when_multiple_scripts_are_named():
request = (
"Write /workspace/prepare.py and /workspace/calculate.py, then run the script."
)
assert agent_loop._requested_verification_command(request) == ""
+398
View File
@@ -0,0 +1,398 @@
from __future__ import annotations
import asyncio
from src import agent_loop
from src.tool_execution import AgentExecutionBridge, bind_execution_bridge
from src.tool_parsing import parse_tool_blocks, strip_tool_blocks
def _collect(stream):
async def run():
return [chunk async for chunk in stream]
return asyncio.run(run())
def test_external_tool_schema_is_scoped_into_model_request(monkeypatch):
observed_tools = []
observed_tool_values = []
observed_messages = []
monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
monkeypatch.setattr(agent_loop, "blocked_tools_for_owner", lambda owner: set())
async def fake_stream(candidates, messages, **kwargs):
observed_messages.extend(messages)
observed_tool_values.append(kwargs.get("tools"))
observed_tools.extend(kwargs.get("tools") or [])
yield 'data: {"delta": "complete"}\n\n'
yield "data: [DONE]\n\n"
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
_collect(agent_loop.stream_agent_loop(
"https://api.openai.com/v1",
"policy-model",
[
{
"role": "assistant",
"content": "Prior visible response.",
"reasoning_content": "transport-private scratchpad",
},
{"role": "user", "content": "Inspect the current state."},
],
max_rounds=1,
owner="pewds",
relevant_tools={"inspect_state"},
forced_tools={"inspect_state"},
fallbacks=[],
fallback_on_empty=False,
external_tool_schemas=[{
"type": "function",
"function": {
"name": "inspect_state",
"description": "Return the current synthetic state.",
"parameters": {"type": "object", "properties": {}},
},
}],
_is_teacher_run=True,
))
matching = [
schema for schema in observed_tools
if schema.get("function", {}).get("name") == "inspect_state"
]
assert len(matching) == 1, [
schema.get("function", {}).get("name")
for value in observed_tool_values
for schema in (value or [])
]
assert matching[0]["function"]["description"] == "Return the current synthetic state."
system_text = next(message["content"] for message in observed_messages if message["role"] == "system")
assert "request-scoped environment" in system_text
assert "local-machine mode" not in system_text
assert len(system_text) < 1000
assert all("reasoning_content" not in message for message in observed_messages)
def test_external_tool_schema_can_use_textual_transport_from_first_request(monkeypatch):
observed_tools = []
observed_messages = []
monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
monkeypatch.setattr(agent_loop, "blocked_tools_for_owner", lambda owner: set())
async def fake_stream(candidates, messages, **kwargs):
observed_messages.extend(messages)
observed_tools.append(kwargs.get("tools"))
yield 'data: {"delta": "complete"}\n\n'
yield "data: [DONE]\n\n"
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
chunks = _collect(agent_loop.stream_agent_loop(
"https://policy.invalid/v1",
"policy-model",
[{"role": "user", "content": "Inspect the current state."}],
max_rounds=1,
owner="pewds",
relevant_tools={"inspect_state"},
forced_tools={"inspect_state"},
fallbacks=[],
fallback_on_empty=False,
external_tool_schemas=[{
"type": "function",
"function": {
"name": "inspect_state",
"description": "Return the current synthetic state.",
"parameters": {"type": "object", "properties": {}},
},
}],
force_textual_tool_transport=True,
_is_teacher_run=True,
))
assert observed_tools == [None]
delta_events = [
__import__("json").loads(chunk[6:])
for chunk in chunks
if chunk.startswith("data: {") and '"delta"' in chunk
]
assert delta_events[0]["round"] == 1
system_text = next(message["content"] for message in observed_messages if message["role"] == "system")
assert "inspect_state" in system_text
assert "Environment tools declared for this turn" in system_text
assert "fenced block" in system_text
def test_declared_textual_tool_fence_is_request_scoped():
text = 'before\n```inspect_state\n{"scope":"active"}\n```\nafter'
assert parse_tool_blocks(text) == []
[block] = parse_tool_blocks(text, additional_tool_names={"inspect_state"})
assert block.tool_type == "inspect_state"
assert block.content == '{"scope":"active"}'
assert strip_tool_blocks(text, additional_tool_names={"inspect_state"}) == "before\n\nafter"
def test_json_fence_can_name_only_a_request_declared_tool():
text = '```json\ninspect_state\n{"scope":"active"}\n```'
assert all(block.tool_type != "inspect_state" for block in parse_tool_blocks(text))
[block] = parse_tool_blocks(text, additional_tool_names={"inspect_state"})
assert block.tool_type == "inspect_state"
assert block.content == '{"scope": "active"}'
def test_split_json_fence_can_name_only_a_request_declared_tool():
text = '```json\ninspect_state\n```\n{"scope":"active"}\n```'
assert parse_tool_blocks(text) == []
[block] = parse_tool_blocks(text, additional_tool_names={"inspect_state"})
assert block.tool_type == "inspect_state"
assert block.content == '{"scope": "active"}'
def test_direct_xml_can_name_only_a_request_declared_tool():
text = 'before\n```json\n<inspect_state>{"scope":"active"}</inspect_state>\n```\nafter'
assert parse_tool_blocks(text) == []
[block] = parse_tool_blocks(text, additional_tool_names={"inspect_state"})
assert block.tool_type == "inspect_state"
assert block.content == '{"scope": "active"}'
assert strip_tool_blocks(text, additional_tool_names={"inspect_state"}) == "before\n\nafter"
def test_adjacent_fences_can_name_only_a_request_declared_tool():
text = '```bash\ninspect_state\n```\n```json\n{"scope":"active"}\n```'
[ordinary_block] = parse_tool_blocks(text)
assert ordinary_block.tool_type == "bash"
[block] = parse_tool_blocks(text, additional_tool_names={"inspect_state"})
assert block.tool_type == "inspect_state"
assert block.content == '{"scope": "active"}'
def test_adjacent_fences_can_put_declared_arguments_before_tool_name():
text = '```json\n{"scope":"active"}\n```\n```bash\ninspect_state\n```'
[block] = parse_tool_blocks(text, additional_tool_names={"inspect_state"})
assert block.tool_type == "inspect_state"
assert block.content == '{"scope": "active"}'
def test_language_fence_can_wrap_a_request_declared_tool_envelope():
text = """```python
write_file
/workspace/output.html
<main>Complete output</main>
```"""
[block] = parse_tool_blocks(
text,
additional_tool_names={"python", "write_file"},
)
assert block.tool_type == "write_file"
assert block.content == "/workspace/output.html\n<main>Complete output</main>"
def test_language_fence_can_repeat_its_declared_tool_name():
text = """```python
python
print('verified')
```"""
[block] = parse_tool_blocks(text, additional_tool_names={"python"})
assert block.tool_type == "python"
assert block.content == "print('verified')"
def test_name_only_json_fence_dispatches_declared_tool_with_empty_arguments():
text = '```json\ninspect_state\n```'
assert parse_tool_blocks(text) == []
[block] = parse_tool_blocks(text, additional_tool_names={"inspect_state"})
assert block.tool_type == "inspect_state"
assert block.content == "{}"
def test_external_textual_history_omits_nonstandard_reasoning_field():
messages = []
agent_loop._append_tool_results(
messages,
'```inspect_state\n{"scope":"active"}\n```',
[],
["inspect_state: available"],
["inspect_state: available"],
False,
1,
round_reasoning="private scratchpad",
include_reasoning_content=False,
)
assert all("reasoning_content" not in message for message in messages)
def test_external_tool_images_are_threaded_as_multimodal_evidence():
messages = []
agent_loop._append_tool_results(
messages,
'```read_media\n{"path":"/workspace/clip.mp4"}\n```',
[],
["read_media: frames extracted"],
["read_media: frames extracted"],
False,
1,
tool_result_records=[{
"tool_name": "read_media",
"content": '{"path":"/workspace/clip.mp4"}',
"result": {
"output": "frames extracted",
"images": [
{"mimeType": "image/png", "data": "frame-a"},
{"mimeType": "image/jpeg", "data": "frame-b"},
],
},
}],
)
evidence = messages[-1]
assert evidence["metadata"]["trusted"] is False
assert evidence["content"][0]["type"] == "text"
assert [block["image_url"]["url"] for block in evidence["content"][1:]] == [
"data:image/png;base64,frame-a",
"data:image/jpeg;base64,frame-b",
]
def test_declared_external_call_reaches_scoped_bridge(monkeypatch):
bridge_calls = []
round_no = 0
monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
monkeypatch.setattr(agent_loop, "blocked_tools_for_owner", lambda owner: set())
async def fake_stream(candidates, messages, **kwargs):
nonlocal round_no
round_no += 1
if round_no == 1:
yield 'data: {"type": "tool_calls", "calls": [{"id": "call-neutral-1", "name": "inspect_state", "arguments": "{\\"scope\\":\\"active\\"}"}]}\n\n'
else:
yield 'data: {"delta": "complete"}\n\n'
yield "data: [DONE]\n\n"
async def route(tool, content, session_id, runtime_context):
bridge_calls.append((tool, content))
return "inspect_state", {"output": "available", "exit_code": 0}
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
bridge = AgentExecutionBridge(
route_tool=route,
supported_tools=frozenset({"inspect_state"}),
name="synthetic_environment",
)
with bind_execution_bridge(bridge):
_collect(agent_loop.stream_agent_loop(
"https://api.openai.com/v1",
"policy-model",
[{"role": "user", "content": "Perform the declared operation."}],
max_rounds=2,
owner="pewds",
relevant_tools={"inspect_state"},
forced_tools={"inspect_state"},
fallbacks=[],
fallback_on_empty=False,
external_tool_schemas=[{
"type": "function",
"function": {
"name": "inspect_state",
"description": "Return the current synthetic state.",
"parameters": {
"type": "object",
"properties": {"scope": {"type": "string"}},
},
},
}],
_is_teacher_run=True,
))
assert bridge_calls == [("inspect_state", '{"scope": "active"}')]
def test_known_native_tool_reaches_scoped_bridge_without_redeclared_schema(monkeypatch):
bridge_calls = []
round_no = 0
monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
monkeypatch.setattr(
agent_loop,
"blocked_tools_for_owner",
lambda owner: {"search_emails"},
)
async def fake_stream(candidates, messages, **kwargs):
nonlocal round_no
round_no += 1
if round_no == 1:
offered = {
schema["function"]["name"]
for schema in kwargs.get("tools") or []
}
assert "search_emails" in offered
yield 'data: {"type": "tool_calls", "calls": [{"id": "call-search-1", "name": "search_emails", "arguments": "{\\"query\\":\\"Project Alpha\\"}"}]}\n\n'
else:
yield 'data: {"delta": "complete"}\n\n'
yield "data: [DONE]\n\n"
async def route(tool, content, session_id, runtime_context):
bridge_calls.append((tool, content))
return "search_emails", {"output": "matching messages", "exit_code": 0}
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
bridge = AgentExecutionBridge(
route_tool=route,
supported_tools=frozenset({
"search_emails",
"mcp__email__search_emails",
}),
name="native_environment",
)
with bind_execution_bridge(bridge):
_collect(agent_loop.stream_agent_loop(
"https://api.openai.com/v1",
"policy-model",
[{"role": "user", "content": "Search email for Project Alpha."}],
max_rounds=2,
owner="public-user",
relevant_tools={"search_emails"},
forced_tools={"search_emails"},
fallbacks=[],
fallback_on_empty=False,
client_runtime_context={
"surface": "odysseus-native",
"unattended_mode": True,
},
_is_teacher_run=True,
))
assert bridge_calls == [
("mcp__email__search_emails", '{"query": "Project Alpha"}'),
]
+165
View File
@@ -2,7 +2,9 @@
and _append_tool_results. Uses mock imports to avoid loading the full app stack."""
import sys
import json
from unittest.mock import MagicMock
from src.tool_types import ToolBlock
_MOCKED_IMPORTS = [
'sqlalchemy', 'sqlalchemy.orm', 'sqlalchemy.ext', 'sqlalchemy.ext.declarative',
@@ -40,7 +42,12 @@ try:
_compute_final_metrics,
_append_tool_results,
_insert_before_latest_user,
_normalize_web_search_block_query,
_MCP_KEYWORDS,
_web_search_query_from_block,
_web_search_topic_text,
_parse_qwen_explicit_email_topic_bulk_action_request,
_email_bulk_blocks_from_search_output,
)
_IMPORTED_AGENT_LOOP = sys.modules.get("src.agent_loop")
finally:
@@ -64,6 +71,48 @@ def test_mcp_keyword_gate_matches_literal_mcp_requests():
assert "mcp" in _MCP_KEYWORDS
def test_topic_bulk_email_request_searches_before_mutating():
parsed = _parse_qwen_explicit_email_topic_bulk_action_request(
"delete all my traffic monitor emails"
)
assert parsed == {
"action": "delete",
"query": "traffic monitor",
"folder": "INBOX",
"max_results": 50,
}
def test_topic_bulk_email_blocks_split_by_account_from_search_output():
raw = """Found 2 email(s):
1. **Traffic monitor stale**
From: Alert Bot (alerts@example.com)
Date: 2026-08-31T08:00:00+00:00
UID: 101
Account: Primary Inbox <alex@example.com>
2. **Traffic monitor active**
From: Alert Bot (alerts@example.com)
Date: 2026-08-31T09:00:00+00:00
UID: 202
Account: Research Mail <research@example.com>
"""
blocks = _email_bulk_blocks_from_search_output(raw, action="delete")
assert [block.tool_type for block in blocks] == [
"mcp__email__bulk_email",
"mcp__email__bulk_email",
]
args = [json.loads(block.content) for block in blocks]
assert args == [
{"action": "delete", "uids": ["101"], "folder": "INBOX", "account": "alex@example.com"},
{"action": "delete", "uids": ["202"], "folder": "INBOX", "account": "research@example.com"},
]
def test_polish_internet_search_request_classifies_as_web():
intent = _classify_agent_request(
[],
@@ -104,6 +153,122 @@ def test_insert_before_latest_user_appends_when_no_user_message_exists():
assert _insert_before_latest_user(messages, context) == [messages[0], context]
def test_web_search_topic_uses_prior_user_for_generic_search_followup():
messages = [
{"role": "user", "content": "which swedish king liked to dance hte most"},
{"role": "assistant", "content": "Probably Gustav III."},
{"role": "user", "content": "can you search"},
]
assert (
_web_search_topic_text(messages, "can you search")
== "which swedish king liked to dance hte most"
)
def test_web_search_topic_ignores_untrusted_context_when_resolving_followup():
messages = [
{"role": "user", "content": "which swedish king liked to dance hte most"},
{
"role": "user",
"metadata": {"trusted": False},
"content": "UNTRUSTED SOURCE DATA\nwrong query",
},
{"role": "user", "content": "can you search"},
]
assert (
_web_search_topic_text(messages, "can you search")
== "which swedish king liked to dance hte most"
)
def test_web_search_normalizer_replaces_non_query_tool_argument_with_topic():
block = ToolBlock("web_search", "can you search")
out = _normalize_web_search_block_query(
block,
"which swedish king liked to dance hte most",
)
assert _web_search_query_from_block(out) == "which swedish king liked to dance hte most"
def test_web_search_normalizer_trusts_model_chosen_contextual_query():
block = ToolBlock("web_search", "Gustav III Sweden dancing masquerade ball")
out = _normalize_web_search_block_query(
block,
"which swedish king liked to dance hte most",
)
assert _web_search_query_from_block(out) == "Gustav III Sweden dancing masquerade ball"
def test_web_search_normalizer_removes_action_wrapper_and_leads_with_subject():
block = ToolBlock(
"web_search",
'{"query":"Search the web for the official IKEA website"}',
)
out = _normalize_web_search_block_query(
block,
"Search the web for the official IKEA website",
)
assert json.loads(out.content)["query"] == "IKEA official website"
def test_official_website_answer_uses_matching_shortest_evidence_url():
from src.agent_loop import _official_website_answer_from_search
output = """
[1] Shop Affordable Home Furnishings & Home Goods - IKEA
URL: https://www.ikea.com/us/en/?tracking=1
[2] Hej! Welcome to IKEA Global
URL: https://www.ikea.com/?tracking=1
"""
assert _official_website_answer_from_search(
"Search the web for the official IKEA website", output
) == "The official IKEA website is https://www.ikea.com/."
assert _official_website_answer_from_search(
"Find IKEA chairs", output
) == ""
def test_web_search_normalizer_preserves_entity_identified_from_attached_image():
block = ToolBlock("web_search", "Xiaomi SU7 current price 2026")
out = _normalize_web_search_block_query(
block,
(
"How much does this car sell for now? Please use the attached image. "
"If exact pricing is uncertain, provide a plausible range and explain why."
),
)
assert _web_search_query_from_block(out) == "Xiaomi SU7 current price 2026"
def test_web_search_normalizer_trusts_json_query_and_preserves_time_filter():
block = ToolBlock(
"web_search",
'{"query":"Gustav III Sweden dancing masquerade ball","time_filter":"year"}',
)
out = _normalize_web_search_block_query(
block,
"which swedish king liked to dance hte most",
)
payload = json.loads(out.content)
assert payload == {
"query": "Gustav III Sweden dancing masquerade ball",
"time_filter": "year",
}
# ---------------------------------------------------------------------------
# _detect_admin_intent
# ---------------------------------------------------------------------------
@@ -7,6 +7,7 @@ a ``... (truncated, N chars total)`` suffix so the frontend can show a
truncation indicator in the tool bubble.
"""
from src.tool_utils import _truncate, MAX_OUTPUT_CHARS
from src.tool_execution import format_tool_result
def test_short_output_unchanged():
@@ -41,3 +42,29 @@ def test_default_limit_matches_constant():
def test_empty_string():
assert _truncate("") == ""
def test_external_bridge_result_is_bounded_before_model_replay():
text = "package output\n" * MAX_OUTPUT_CHARS
rendered = format_tool_result(
"bash: install dependencies",
{"output": text, "exit_code": 0},
)
assert len(rendered) < MAX_OUTPUT_CHARS + 100
assert "truncated" in rendered
def test_format_tool_result_does_not_serialize_image_payloads_as_json():
rendered = format_tool_result(
"read media",
{
"output": "frames extracted",
"exit_code": 0,
"images": [{"mimeType": "image/png", "data": "base64-frame-data"}],
},
)
assert "frames extracted" in rendered
assert "base64-frame-data" not in rendered
+88
View File
@@ -0,0 +1,88 @@
"""Execute the route's production state machine without importing app services.
AST loading isolates the dependency-free class; assertions are event/content
behavior, never source text or a reimplementation of the routing logic.
Also runnable with `python tests/test_agent_render_ownership.py`.
"""
import ast
from pathlib import Path
import unittest
path = Path(__file__).resolve().parents[1] / "routes/chat_routes.py"
tree = ast.parse(path.read_text())
node = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "_AgentRenderState")
namespace = {}
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), "exec"), namespace)
State = namespace["_AgentRenderState"]
class AgentRenderOwnershipTests(unittest.TestCase):
def test_plain_deltas_append_and_save_streamed_owner(self):
state = State()
self.assertEqual(state.consume({"delta": "Hello "})["render_owner"], "streamed")
state.consume({"delta": "world"})
self.assertEqual(state.content, "Hello world")
self.assertEqual(state.metadata({"tokens": 2}), {"tokens": 2, "render_owner": "streamed"})
self.assertEqual(state.message_saved(42), {"type": "message_saved", "id": 42, "render_owner": "streamed"})
def test_final_snapshot_replaces_draft_and_persists_ownership(self):
state = State()
state.consume({"delta": "Draft notes"})
event = state.consume({"type": "final_response", "content": "Canonical notes"})
self.assertEqual(state.content, "Canonical notes")
self.assertEqual(event["render_owner"], "structured")
self.assertEqual(event["replacement_scope"], "turn")
metrics = state.metadata({"tool_events": [{"tool": "manage_notes"}]})
self.assertEqual(metrics["render_owner"], "structured")
self.assertEqual(metrics["replacement_scope"], "turn")
self.assertEqual(state.message_saved(1)["render_owner"], metrics["render_owner"])
self.assertEqual(state.message_saved(1)["replacement_scope"], "turn")
def test_later_synthesis_replaces_intermediate_snapshot_and_is_not_dropped(self):
state = State()
state.consume({"type": "final_response", "content": "Intermediate list"})
state.consume({"type": "agent_step", "round": 2})
first = state.consume({"delta": "Synthesis "})
second = state.consume({"delta": "continues"})
self.assertEqual(first["render_owner"], "streamed")
self.assertEqual(first["replacement_scope"], "turn")
self.assertNotIn("replacement_scope", second)
self.assertEqual(state.content, "Synthesis continues")
self.assertEqual(state.metadata()["render_owner"], "streamed")
self.assertEqual(state.message_saved(1)["render_owner"], "streamed")
self.assertEqual(state.message_saved(1)["replacement_scope"], "turn")
def test_thinking_and_empty_deltas_do_not_take_answer_ownership(self):
state = State()
state.consume({"type": "final_response", "content": "Notes"})
event = state.consume({"delta": "Reasoning", "thinking": True})
state.consume({"delta": ""})
self.assertEqual(event["render_owner"], "structured")
self.assertEqual(state.content, "Notes")
self.assertEqual(state.message_saved(1)["render_owner"], "structured")
def test_final_correction_after_synthesis_wins_without_duplicate_content(self):
state = State()
for event in [
{"delta": "Draft"},
{"type": "final_response", "content": "List"},
{"delta": "Synthesis"},
{"type": "final_response", "content": "Corrected answer"},
{"type": "final_response", "content": "Corrected answer"},
]:
state.consume(event)
self.assertEqual(state.content, "Corrected answer")
self.assertEqual(state.metadata()["render_owner"], "structured")
def test_explicit_streamed_final_and_delta_form_snapshot(self):
state = State()
state.consume({"delta": "Old"})
event = state.consume({"type": "final_response", "delta": "Replacement", "render_owner": "streamed"})
self.assertEqual(event["replacement_scope"], "turn")
self.assertEqual(state.content, "Replacement")
self.assertEqual(state.metadata()["render_owner"], "streamed")
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
import asyncio
from src import agent_runs
def test_done_is_published_only_after_generator_cleanup():
async def scenario():
session_id = "terminal-order-test"
cleanup_finished = asyncio.Event()
async def stream():
yield 'data: {"delta":"answer"}\n\n'
yield "data: [DONE]\n\n"
await asyncio.sleep(0)
cleanup_finished.set()
run = agent_runs.start(session_id, stream())
received = []
async for event in agent_runs.subscribe(session_id, run):
received.append(event)
if event.strip() == "data: [DONE]":
assert cleanup_finished.is_set()
assert [event.strip() for event in received] == [
'data: {"delta":"answer"}',
"data: [DONE]",
]
agent_runs._RUNS.pop(session_id, None)
asyncio.run(scenario())
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
from src.agent_loop import (
_is_tool_preamble,
_memory_list_summary_from_tool_output,
_session_list_summary_from_tool_output,
_web_search_queries_overlap,
_web_search_query_from_block,
)
from src.tool_types import ToolBlock
def test_near_duplicate_web_search_queries_are_detected():
assert _web_search_queries_overlap(
"latest Python release version",
"Python 3.14 release python.org latest version",
)
def test_distinct_web_search_topics_are_not_collapsed():
assert not _web_search_queries_overlap(
"latest Python release",
"how to install Python on Arch Linux",
)
def test_different_explicit_versions_are_not_collapsed():
assert not _web_search_queries_overlap(
"Python 3.13 release notes",
"Python 3.14 release notes",
)
def test_web_search_query_extractor_accepts_json_and_plain_blocks():
assert _web_search_query_from_block(
ToolBlock("web_search", '{"query":"latest Python release"}')
) == "latest Python release"
assert _web_search_query_from_block(
ToolBlock("web_search", "latest Python release")
) == "latest Python release"
def test_tool_preamble_is_separate_from_a_substantive_tool_round_answer():
assert _is_tool_preamble("I'll fetch the official Python documentation now.")
assert _is_tool_preamble("Let me check the source.")
assert not _is_tool_preamble(
"I'll check the source, then compare the supported versions and explain the difference. "
"The answer depends on the installed runtime."
)
def test_broad_memory_listing_keeps_bounded_reviewable_items():
raw = (
"Found 257 memory entries:\n\n"
"- [fact] `a1` — private detail\n"
"- [fact] `a2` — another detail\n"
"- [preference] `b1` — hidden preference\n"
)
summary = _memory_list_summary_from_tool_output(raw)
assert summary.startswith("Memory: 257 saved entries (fact 2, preference 1).")
assert "- [fact a1](#memory-a1) — private detail" in summary
assert "- [preference b1](#memory-b1) — hidden preference" in summary
assert "...and 254 more saved memories." in summary
def test_compact_memory_listing_is_already_a_complete_summary():
raw = "Memory: 257 saved entries (contact 9, fact 98, preference 63)."
assert _memory_list_summary_from_tool_output(raw) == raw
def test_session_listing_is_bounded_for_terminal_router():
raw = "Found 20 session(s), sorted most-recent first:\n" + "\n".join(
f"- session {index}" for index in range(20)
)
summary = _session_list_summary_from_tool_output(raw)
assert summary.startswith("Found 20 session(s)")
assert sum(line.startswith("- session ") for line in summary.splitlines()) == 12
assert "...and more sessions" in summary
assert "session 19" not in summary
+20
View File
@@ -0,0 +1,20 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_agent_thread_chevron_uses_css_shape_in_live_and_history_renderers():
live = (ROOT / "static/js/chat.js").read_text()
history = (ROOT / "static/js/chatRenderer.js").read_text()
css = (ROOT / "static/style.css").read_text()
for src in (live, history):
assert 'class="agent-thread-chevron" aria-hidden="true"></span>' in src
assert 'class="agent-thread-chevron">\\u25B6</span>' not in src
assert ".agent-thread-chevron::before" in css
assert "border-top: 4px solid transparent;" in css
assert "border-bottom: 4px solid transparent;" in css
assert "border-left: 6px solid currentColor;" in css
assert ".agent-thread-node.open .agent-thread-chevron::before" in css
@@ -0,0 +1,9 @@
"""Collection must not replace the agent-tools package with a test double."""
import src.agent_tools
from src.agent_tools.web_tools import PrivateBrowserTool
def test_agent_tools_remains_a_real_package_after_test_collection():
assert src.agent_tools.__path__
assert PrivateBrowserTool.__module__ == "src.agent_tools.web_tools"
+272
View File
@@ -0,0 +1,272 @@
from __future__ import annotations
import json
import pytest
from src.agent_evidence import CompletionRequirements
from src.agent_trace import (
CanonicalTrace,
TraceKind,
audit_completion_trace,
completion_from_trace,
decode_typed_trace,
decode_native_trace,
decode_nemo_trajectory,
tool_events_from_trace,
)
REQUIREMENTS = CompletionRequirements(
required_artifacts=("/workspace/app.py",),
verifier_required=True,
executable_verifier_available=True,
verifier_commands=("./test.sh",),
)
def _sse(value):
return {"elapsed_s": 1.0, "sse": f"data: {json.dumps(value)}\n\n"}
def test_native_trace_round_trip_and_completion_rehydration(tmp_path):
records = [
{"type": "media_ingress", "data": {"artifacts": []}},
_sse({"type": "tool_start", "tool": "write_file", "command": "/workspace/app.py\nVALUE = 2", "round": 1}),
_sse({"type": "tool_output", "tool": "write_file", "command": "/workspace/app.py\nVALUE = 2", "output": "wrote", "exit_code": 0}),
_sse({"type": "tool_start", "tool": "bash", "command": "./test.sh", "round": 1}),
_sse({"type": "tool_output", "tool": "bash", "command": "./test.sh", "output": "passed", "exit_code": 0}),
{"elapsed_s": 2.0, "sse": "data: [DONE]\n\n"},
]
trace = decode_native_trace(records, run_id="native-1")
path = trace.write_jsonl(tmp_path / "trace.jsonl")
restored = CanonicalTrace.read_jsonl(path)
assert restored.to_list() == trace.to_list()
assert completion_from_trace(restored, REQUIREMENTS).to_dict()["status"] == "verified"
assert restored.summary()["complete"] is True
def test_native_trace_run_start_records_runtime_revision():
trace = decode_native_trace(
[{"sse": "data: [DONE]\n\n"}],
run_id="native-revision",
runtime_revision="revision-under-test",
)
assert trace.events[0].kind == TraceKind.RUN_START
assert trace.events[0].payload["runtime_revision"] == "revision-under-test"
def test_typed_trace_run_start_records_runtime_revision():
trace = decode_typed_trace(
[
{
"type": "trace_start",
"trace_id": "typed-revision",
"task_id": "task",
"model": "model",
"runtime_revision": "revision-under-test",
},
{"type": "trace_end", "trace_id": "typed-revision"},
]
)
assert trace.events[0].kind == TraceKind.RUN_START
assert trace.events[0].payload["runtime_revision"] == "revision-under-test"
def test_typed_and_native_codecs_rehydrate_same_completion_contract():
native = decode_native_trace(
[
_sse({"type": "tool_start", "tool": "write_file", "command": "/workspace/app.py\nVALUE = 2", "round": 1}),
_sse({"type": "tool_output", "tool": "write_file", "command": "/workspace/app.py\nVALUE = 2", "output": "wrote", "exit_code": 0}),
_sse({"type": "tool_start", "tool": "bash", "command": "./test.sh", "round": 1}),
_sse({"type": "tool_output", "tool": "bash", "command": "./test.sh", "output": "passed", "exit_code": 0}),
{"sse": "data: [DONE]\n\n"},
],
run_id="native",
)
typed = decode_typed_trace(
[
{"type": "trace_start", "trace_id": "typed", "task_id": "task", "model": "model"},
{
"type": "tool_dispatch",
"trace_id": "typed",
"tool_use_id": "write-1",
"tool_name": "write_file",
"endpoint_url": "local",
"request_body": {"path": "/workspace/app.py", "content": "VALUE = 2"},
"response_status": 200,
"response_body": {"output": "wrote", "exit_code": 0},
},
{
"type": "tool_dispatch",
"trace_id": "typed",
"tool_use_id": "verify-1",
"tool_name": "bash",
"endpoint_url": "local",
"request_body": {"command": "./test.sh"},
"response_status": 200,
"response_body": {"output": "passed", "exit_code": 0},
},
{"type": "trace_end", "trace_id": "typed", "passed": True},
]
)
native_decision = completion_from_trace(native, REQUIREMENTS)
typed_decision = completion_from_trace(typed, REQUIREMENTS)
assert native_decision.status == typed_decision.status
assert native_decision.can_complete == typed_decision.can_complete is True
assert native_decision.missing_artifacts == typed_decision.missing_artifacts == ()
def test_nemo_codec_preserves_structured_arguments_and_observation_gaps():
trace = decode_nemo_trajectory(
{
"schema_version": "1.0",
"task_id": "task",
"rollout_id": "rollout-7",
"invocations": [{"kind": "agent_invocation", "invocation_id": "root", "status": "completed"}],
"turns": [],
"model_calls": [],
"tool_calls": [
{
"kind": "tool_call",
"invocation_id": "root",
"tool_call_id": "call-1",
"tool_name": "write_file",
"arguments": {"path": "/workspace/app.py", "content": "VALUE = 2"},
"output": {"output": "wrote", "exit_code": 0},
"status": "completed",
}
],
"gaps": [{"code": "model_call_ownership_unavailable", "invocation_id": "root"}],
}
)
[event] = [event for event in trace.events if event.kind == TraceKind.TOOL_CALL]
assert event.payload["arguments"] == {"path": "/workspace/app.py", "content": "VALUE = 2"}
assert trace.summary()["observation_gaps"] == ["model_call_ownership_unavailable"]
def test_native_codec_records_unmatched_tool_result_as_gap():
trace = decode_native_trace(
[_sse({"type": "tool_output", "tool": "bash", "command": "pwd", "output": "/workspace", "exit_code": 0})],
run_id="partial",
)
assert trace.summary()["observation_gaps"] == ["tool_result_call_unmatched", "trace_end_unavailable"]
def test_native_codec_preserves_model_response_reference():
trace = decode_native_trace(
[
_sse({
"type": "model_response_ref",
"response_id": "response-neutral-1",
"model": "policy-model",
"round": 2,
}),
{"sse": "data: [DONE]\n\n"},
],
run_id="model-ref",
)
[event] = [event for event in trace.events if event.kind == TraceKind.MODEL_CALL]
assert event.correlation_id == "response-neutral-1"
assert event.round == 2
assert event.payload == {
"response_id": "response-neutral-1",
"model": "policy-model",
}
def test_canonical_reader_rejects_noncontiguous_sequence(tmp_path):
trace = decode_native_trace([{"sse": "data: [DONE]\n\n"}], run_id="run")
rows = trace.to_list()
rows[-1]["sequence"] = 99
path = tmp_path / "bad.jsonl"
path.write_text("".join(json.dumps(row) + "\n" for row in rows))
with pytest.raises(ValueError, match="contiguous"):
CanonicalTrace.read_jsonl(path)
def test_tool_result_rehydration_keeps_call_identity():
trace = decode_typed_trace(
[
{"type": "trace_start", "trace_id": "x", "task_id": "t", "model": "m"},
{
"type": "tool_dispatch",
"trace_id": "x",
"tool_use_id": "exact-call-id",
"tool_name": "bash",
"endpoint_url": "local",
"request_body": {"command": "pwd"},
"response_status": 200,
"response_body": {"output": "/workspace", "exit_code": 0},
},
{"type": "trace_end", "trace_id": "x"},
]
)
[event] = tool_events_from_trace(trace)
assert event["tool_call_id"] == "exact-call-id"
assert event["command"] == "pwd"
def test_completion_audit_detects_source_replay_disagreement():
trace = decode_native_trace(
[
_sse({
"type": "completion_decision",
"data": {
"status": "verified",
"can_complete": True,
"reason": "claimed",
"evidence_ids": [],
"missing_artifacts": [],
},
}),
{"sse": "data: [DONE]\n\n"},
],
run_id="disagreement",
)
audit = audit_completion_trace(trace, REQUIREMENTS)
assert audit["persisted_available"] is True
assert audit["agreement"] is False
assert audit["differences"] == ["status", "can_complete", "missing_artifacts"]
assert audit["recomputed"]["status"] == "blocked"
def test_completion_audit_replays_round_exhaustion_from_trace():
trace = decode_native_trace(
[
_sse({"type": "rounds_exhausted", "rounds": 12}),
_sse({
"type": "completion_decision",
"data": {
"status": "exhausted",
"can_complete": False,
"reason": "the run exhausted its model-round budget",
"evidence_ids": [],
"missing_artifacts": [],
},
}),
{"sse": "data: [DONE]\n\n"},
],
run_id="exhaustion-replay",
)
audit = audit_completion_trace(trace)
assert audit["agreement"] is True
assert audit["recomputed"]["status"] == "exhausted"
def test_completion_audit_explicit_exhaustion_override_wins():
trace = decode_native_trace(
[_sse({"type": "rounds_exhausted", "rounds": 12}), {"sse": "data: [DONE]\n\n"}],
run_id="exhaustion-override",
)
audit = audit_completion_trace(trace, exhausted=False)
assert audit["recomputed"]["status"] == "unverified"
@@ -0,0 +1,136 @@
"""Exercise loop boundaries without booting the application or its services."""
import ast
from pathlib import Path
from types import SimpleNamespace
import pytest
SOURCE = Path(__file__).resolve().parents[1] / "src" / "agent_loop.py"
def load_function(name, namespace=None):
tree = ast.parse(SOURCE.read_text())
node = next(n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef) and n.name == name)
namespace = {} if namespace is None else namespace
exec(compile(ast.Module(body=[node], type_ignores=[]), str(SOURCE), "exec"), namespace)
return namespace[name]
def contract(*families):
schema = {"type": "function", "function": {"name": "manage_calendar"}}
return SimpleNamespace(
capabilities=frozenset(families), required=frozenset(),
offered=frozenset({"manage_calendar"}) if families else frozenset(),
permits=lambda name: bool(families) and name == "manage_calendar",
schemas=lambda: [schema] if families else [],
)
@pytest.mark.parametrize("families", [("calendar",), ("calendar", "documents")])
def test_contract_work_cannot_finish_through_single_action_shortcut(families):
allows = load_function("_contract_allows_early_completion")
assert not allows(contract(*families))
assert allows(None)
assert allows(contract())
@pytest.mark.parametrize("policy", [None, SimpleNamespace(blocks=lambda _: False)])
def test_contract_rejection_needs_no_legacy_denial(policy):
reason = load_function("_tool_rejection_reason")
assert "outside" in reason("bash", {"bash"}, policy, contract("calendar"))
assert "disabled" in reason("bash", {"bash"}, policy)
@pytest.mark.parametrize("surface,is_api,router,expected", [
("none", True, False, []),
("", False, False, []),
("", True, True, []),
("compact", True, True, ["manage_calendar"]),
("full", True, False, ["manage_calendar"]),
])
def test_contract_schema_transport(surface, is_api, router, expected):
fn = schema_function()
schemas = fn(route(surface, is_api, router))
assert [s["function"]["name"] for s in schemas] == expected
def route(surface="compact", is_api=True, router=False):
return {"mcp_schemas": [], "relevant_tools": {"bash"},
"qwen38_tool_router": router, "tool_surface": surface,
"is_api_model": is_api}
def schema_function(force_answer=False, keep_artifacts=False, guide_only=False):
namespace = {
"turn_contract": contract("calendar"), "guide_only": guide_only,
"_force_answer": force_answer, "_artifact_recovery_enabled": False,
"_artifact_creation_requested": False, "_artifact_finish_nudge_sent": False,
"_artifact_finish_correction_seen": False,
"_artifact_finish_post_correction_tool_used": False,
"_artifact_finish_post_correction_mutation_seen": False,
"_post_correction_verification_available": lambda **_: False,
"_force_answer_keeps_artifact_tools": lambda **_: keep_artifacts,
"_normalize_model_tool_surface": lambda value: value,
"_tui_local_workspace_turn": lambda *a, **kw: False,
"_retrieval_query": "", "_last_user": "", "workspace": None,
"client_runtime_context": None,
"_apply_tool_surface_to_schemas": lambda schemas, surface: schemas,
}
return load_function("_tool_schemas_for_route", namespace)
def test_synthesis_preserves_existing_artifact_exception():
assert schema_function(force_answer=True)(route()) == []
assert schema_function(force_answer=True, keep_artifacts=True)(route())
assert schema_function(guide_only=True)(route()) == []
def test_early_shortcuts_are_guarded_in_the_loop():
tree = ast.parse(SOURCE.read_text())
triggers = {"_early_active_email_reply_body", "_early_active_email_draft_update",
"_early_active_document_append", "_no_tool_boundary_answer", "_exact_file_edit"}
found = set()
for node in ast.walk(tree):
if not isinstance(node, ast.If):
continue
names = {n.id for n in ast.walk(node.test) if isinstance(n, ast.Name)}
matched = names & triggers
if matched:
assert "_contract_allows_early_completion" in names
found.update(matched)
assert found == triggers
@pytest.mark.parametrize("eligible,summary", [(False, "Thirty notes"), (True, "")])
def test_successful_tool_continues_to_synthesis_without_eligible_answer(eligible, summary):
"""Run the actual post-tool completion branch, including its break."""
tree = ast.parse(SOURCE.read_text())
node = next(n for n in ast.walk(tree) if isinstance(n, ast.If)
and "_ody_notes_tool_completed" in {
v.id for v in ast.walk(n.test) if isinstance(v, ast.Name)})
reached = []
namespace = dict(
_ody_notes_finetune_mode=False, _ody_qwen_finetune_model=False,
_qwen38_tool_router=True, _ody_notes_tool_completed=True,
_deterministic_terminal_eligible=eligible,
_qwen_note_delete_title="", _qwen_note_view_title="",
_qwen_latest_email_open=False, _qwen_latest_email_open_completed=False,
_qwen_latest_email_action=None, _qwen_latest_email_action_completed=False,
tool_result_records=[{"tool_name": "list_cookbook_servers", "content": "",
"result": {"output": "6 configured servers", "exit_code": 0}}],
_ody_qwen_terminal_tool_summary=lambda *a, **kw: summary,
_latest_email_action_needs_followup=lambda *a: False, _last_user="List servers",
full_response="", logger=SimpleNamespace(info=lambda *a: None),
mark_synthesis=lambda: reached.append(True),
)
# Add the actual condition's locator flags with neutral defaults.
for name in {n.id for n in ast.walk(node) if isinstance(n, ast.Name)}:
if name.startswith("_qwen_"):
namespace.setdefault(name, False)
wrapper = ast.parse("def run():\n for _ in range(1):\n pass\n").body[0]
wrapper.body[0].body = [node, ast.Expr(value=ast.Call(func=ast.Name(id="mark_synthesis", ctx=ast.Load()), args=[], keywords=[]))]
module = ast.fix_missing_locations(ast.Module(body=[wrapper], type_ignores=[]))
exec(compile(module, str(SOURCE), "exec"), namespace)
list(namespace["run"]())
assert reached == [True]
+57
View File
@@ -0,0 +1,57 @@
import json
from pathlib import Path
import pytest
from src.agent_tools.filesystem_tools import ApplyPatchTool
import src.agent_tools.filesystem_tools as filesystem_tools
@pytest.mark.asyncio
async def test_apply_patch_rolls_back_every_file_when_commit_fails(
monkeypatch,
tmp_path: Path,
):
first = tmp_path / "first.txt"
second = tmp_path / "second.txt"
first.write_text("before first\n", encoding="utf-8")
second.write_text("before second\n", encoding="utf-8")
patch_text = """*** Begin Patch
*** Update File: first.txt
@@
-before first
+after first
*** Update File: second.txt
@@
-before second
+after second
*** End Patch"""
import src.tool_execution as tool_execution
monkeypatch.setattr(
tool_execution,
"_resolve_tool_path",
lambda path: str(tmp_path / path),
)
original_replace = filesystem_tools.os.replace
calls = 0
def fail_during_second_install(source, destination):
nonlocal calls
calls += 1
if calls == 4:
raise OSError("simulated second-file commit failure")
return original_replace(source, destination)
monkeypatch.setattr(filesystem_tools.os, "replace", fail_during_second_install)
result = await ApplyPatchTool().execute(
json.dumps({"patch": patch_text}),
{},
)
assert result["exit_code"] == 1
assert "simulated second-file commit failure" in result["error"]
assert first.read_text(encoding="utf-8") == "before first\n"
assert second.read_text(encoding="utf-8") == "before second\n"
+2 -1
View File
@@ -94,4 +94,5 @@ def test_frontend_uses_one_renderer_for_live_and_restored_cards():
assert "chatRenderer.renderAskUserCard(json.data || {})" in chat
assert "export function renderAskUserCard" in renderer
assert "renderAskUserCard(pendingAskUser" in renderer
assert "if (role === 'user') removeAskUserCards(box)" in renderer
assert "function _answerAskUserCards(root, text)" in renderer
assert "if (role === 'user') _answerAskUserCards(box," in renderer
@@ -0,0 +1,49 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CHAT = (ROOT / "static/js/chat.js").read_text()
SESSIONS = (ROOT / "static/js/sessions.js").read_text()
CSS = (ROOT / "static/style.css").read_text()
def test_queued_prompts_are_persisted_per_session_and_restored_on_return():
assert "odysseus-queued-agent-requests-v1" in CHAT
assert "function _persistQueuedRequests()" in CHAT
assert "function _restoreQueuedRequestsForCurrentSession()" in CHAT
assert "_restoreQueuedRequestsForCurrentSession();" in CHAT
assert "_drainQueuedAgentRequests();" in CHAT
def test_background_completion_survives_rerender_and_hidden_selected_chat():
assert "odysseus-completed-chat-sessions-v1" in SESSIONS
assert "_persistCompletedSessions();" in SESSIONS
assert "document.visibilityState === 'visible'" in SESSIONS
assert "markStreamComplete(streamSessionId, { force: true })" in CHAT
assert "active.wasAway = true" in CHAT
def test_sidebar_has_clear_working_and_done_states():
assert "session-run-state" in SESSIONS
assert "Agent finished while you were away" in SESSIONS
assert ".session-run-state.is-working" in CSS
assert ".session-run-state.is-done" in CSS
assert ".session-star.notify::after" in CSS
assert "content: '\\2713'" in CSS
assert ".session-star.notify {\n animation: none;" in CSS
assert "spinnerModule.createWhirlpool(12)" in SESSIONS
assert "session-run-whirlpool" in CSS
assert "state.textContent = 'Working'" not in SESSIONS
def test_every_registered_stream_marks_sidebar_working_immediately():
registration = CHAT[CHAT.index("_activeStreams.set(streamSessionId") : CHAT.index("_syncForegroundStreamGlobals();", CHAT.index("_activeStreams.set(streamSessionId"))]
assert "sessionModule.markStreaming(streamSessionId)" in registration
assert ".session-star.provider-logo.processing svg" in CSS
assert ".session-star.provider-logo.processing svg {\n animation: none;" in CSS
def test_selected_chat_that_finished_while_away_notifies():
done = CHAT[CHAT.index("const _completedWhileAway") : CHAT.index("// Force-close thinking", CHAT.index("const _completedWhileAway"))]
assert "markStreamComplete(streamSessionId, { force: true })" in done
assert "_notifyStreamComplete(streamSessionId, streamQuery)" in done
+223
View File
@@ -0,0 +1,223 @@
import json
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from core import database
@pytest.fixture
def store(tmp_path, monkeypatch):
engine = create_engine(f'sqlite:///{tmp_path / "jobs.db"}')
database.Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
monkeypatch.setattr(database, 'SessionLocal', factory)
with factory() as db:
for sid, owner in [('origin', 'alice'), ('other', 'alice'), ('foreign', 'bob')]:
db.add(database.Session(id=sid, owner=owner, name=sid, model='test', endpoint_url='http://model.test'))
db.commit()
yield factory
engine.dispose()
@pytest.mark.asyncio
async def test_background_result_returns_once_to_origin_after_foreground_finishes(store):
from src.background_tool_jobs import BackgroundToolJobs
busy = {'origin'}
calls = []
async def summarize(session, payload):
calls.append(payload)
return 'The trial measured a 17% improvement.'
jobs = BackgroundToolJobs(is_busy=lambda sid: sid in busy, summarize=summarize)
jobs.register('rp-fixture', 'origin', 'alice', 'research', 'trial findings', 2)
jobs.complete('rp-fixture', 'Results: 17% improvement.', [{'url': 'https://example.org/trial'}])
await jobs.tick()
assert jobs.list_for_chat('origin', 'alice')[0]['status'] == 'ready'
assert not calls
busy.clear()
await jobs.tick()
await jobs.tick()
result = jobs.list_for_chat('origin', 'alice')
assert len(calls) == 1
assert result[0]['status'] == 'delivered'
assert '17%' in result[0]['message']['content']
assert '#research-rp-fixture' in result[0]['message']['content']
assert jobs.list_for_chat('other', 'alice') == []
assert jobs.list_for_chat('origin', 'bob') == []
# Recreated worker models a server restart / repeated completion notice.
again = BackgroundToolJobs(is_busy=lambda sid: False, summarize=summarize)
again.complete('rp-fixture', 'duplicate notice', [])
await again.tick()
assert again.list_for_chat('origin', 'alice') == result
def test_chat_cards_expose_small_progress_and_honest_outcomes(store):
from src.background_tool_jobs import BackgroundToolJobs
class ResearchService:
def get_status(self, job_id):
return {'progress': {'phase': 'reading', 'round': 1, 'total_sources': 3,
'private_internal_data': 'must not be polled'}}
jobs = BackgroundToolJobs(is_busy=lambda sid: False, research_handler=ResearchService())
jobs.register('rp-card', 'origin', 'alice', 'research', 'topic', 2)
result = jobs.list_for_chat('origin', 'alice')[0]
assert result['progress'] == {'phase': 'reading', 'round': 1, 'total_sources': 3}
jobs.complete('rp-card', 'No information could be gathered.', [])
result = jobs.list_for_chat('origin', 'alice')[0]
assert result['outcome'] == 'no_sources'
assert result['source_count'] == 0
assert 'payload' not in result
assert jobs.list_for_chat('origin', 'bob') == []
@pytest.mark.asyncio
async def test_foreground_can_start_during_summary_and_delivery_waits(store):
from src.background_tool_jobs import BackgroundToolJobs, background_result_context
from core.models import Session, ChatMessage
from src.clean_agent_preview import conversation
busy = set()
async def summarize(session, payload):
busy.add('origin')
return 'Quick findings.'
jobs = BackgroundToolJobs(is_busy=lambda sid: sid in busy, summarize=summarize)
jobs.register('rp-second', 'origin', 'alice', 'research', 'evidence', 2)
jobs.complete('rp-second', 'The hidden measurement is 314.', [{'url': 'https://example.org/evidence'}])
await jobs.tick()
assert jobs.list_for_chat('origin', 'alice')[0]['status'] == 'ready'
busy.clear()
await jobs.tick()
assert jobs.list_for_chat('origin', 'alice')[0]['status'] == 'delivered'
with store() as db:
saved = db.query(database.ChatMessage).filter_by(session_id='origin').one()
metadata = json.loads(saved.meta_data)
session = Session(id='origin', name='test', endpoint_url='test', model='test', history=[
ChatMessage('user', 'research evidence'), ChatMessage('assistant', 'Quick findings.', metadata),
])
assert '314' in str(session.get_context_messages())
assert '314' in str(conversation(session, [{'role': 'user', 'content': 'What was measured?'}]))
assert '314' in str(background_result_context(metadata))
@pytest.mark.asyncio
async def test_foreign_or_deleted_chat_never_receives_result(store):
from src.background_tool_jobs import BackgroundToolJobs
async def summarize(*args): raise AssertionError('must not run')
jobs = BackgroundToolJobs(is_busy=lambda sid: False, summarize=summarize)
with pytest.raises(ValueError):
jobs.register('rp-foreign', 'foreign', 'alice', 'research', 'query', 2)
jobs.register('rp-deleted', 'origin', 'alice', 'research', 'query', 2)
jobs.complete('rp-deleted', 'body', [])
with store() as db:
db.delete(db.get(database.Session, 'origin'))
db.commit()
await jobs.tick()
assert jobs.list_for_chat('origin', 'alice') == []
@pytest.mark.asyncio
@pytest.mark.parametrize('origin,requested,expected', [('origin', None, 2), ('origin', 0, 20), (None, None, 20)])
async def test_research_start_binds_owner_and_uses_quick_chat_default_only(store, monkeypatch, origin, requested, expected):
from fastapi import FastAPI
import httpx
from routes.research import research_routes
from src.background_tool_jobs import BackgroundToolJobs
monkeypatch.setenv('AUTH_ENABLED', 'false')
monkeypatch.setattr(research_routes, 'resolve_endpoint', lambda *a, **kw: ('http://model.test', 'test', {}))
started = []
class ResearchService:
def start_research(self, **kwargs): started.append(kwargs)
app = FastAPI()
app.state.background_tool_jobs = BackgroundToolJobs(is_busy=lambda sid: False)
@app.middleware('http')
async def identify(request, call_next):
request.state.current_user = 'alice'
return await call_next(request)
app.include_router(research_routes.setup_research_routes(ResearchService()))
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client:
body = {'query': 'fixture'}
if origin: body['origin_chat_id'] = origin
if requested is not None: body['max_rounds'] = requested
response = await client.post('/api/research/start', json=body)
assert response.status_code == 200, response.text
assert started[0]['max_rounds'] == expected
assert bool(started[0]['on_complete']) == bool(origin)
rows = (await client.get('/api/research/chat-jobs/origin')).json()['jobs']
assert len(rows) == (1 if origin else 0)
blocked = await client.post('/api/research/start', json={'query': 'fixture', 'origin_chat_id': 'foreign'})
assert blocked.status_code == 404
assert len(started) == 1
@pytest.mark.asyncio
@pytest.mark.parametrize('rounds', [None, 0, 5])
async def test_tool_cannot_spoof_origin_and_defaults_to_two_rounds(monkeypatch, rounds):
import httpx
from src.tools.research import do_trigger_research
posted = []
class Client:
def __init__(self, **kwargs): pass
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
async def post(self, url, **kwargs):
posted.append(kwargs['json'])
return httpx.Response(200, json={'session_id': 'rp-fixture'})
monkeypatch.setattr(httpx, 'AsyncClient', Client)
arguments = {'topic': 'AI', 'origin_chat_id': 'foreign'}
if rounds is not None: arguments['max_rounds'] = rounds
result = await do_trigger_research(json.dumps(arguments),
owner='alice', chat_session_id='origin')
assert result['exit_code'] == 0
expected = {'query': 'AI', 'origin_chat_id': 'origin', 'max_rounds': 2 if rounds is None else rounds}
if rounds is None: expected['max_time'] = 120
assert posted[0] == expected
@pytest.mark.asyncio
async def test_summary_makes_an_actual_model_request(monkeypatch):
import httpx
from types import SimpleNamespace
from src import llm_core
from src.background_tool_jobs import BackgroundToolJobs
requests = []
def respond(request):
requests.append(json.loads(request.content))
return httpx.Response(200, json={'choices': [{'message': {'content': 'Aster measured 17% improvement.'}}]})
async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as client:
monkeypatch.setattr(llm_core, '_get_http_client', lambda: client)
session = SimpleNamespace(id='summary-fixture', endpoint_url='http://summary-model.test/v1/chat/completions', model='fixture')
result = await BackgroundToolJobs(is_busy=lambda sid: False)._summarize(session, {
'job_id': 'rp-summary', 'query': 'Aster trial', 'report': 'Aster improved 17%.', 'sources': [], 'rounds': 2,
})
assert len(requests) == 1
assert '17%' in result
assert 'Aster improved 17%' in str(requests[0]['messages'])
assert not requests[0].get('tools')
@pytest.mark.asyncio
async def test_failed_job_returns_honest_notice_without_model_summary(store):
from src.background_tool_jobs import BackgroundToolJobs
async def summarize(*args): raise AssertionError('No report to summarize')
jobs = BackgroundToolJobs(is_busy=lambda sid: False, summarize=summarize)
jobs.register('rp-error', 'origin', 'alice', 'research', 'query', 2)
jobs.complete('rp-error', 'Research timed out before collecting evidence.', [], error=True)
await jobs.tick()
row = jobs.list_for_chat('origin', 'alice')[0]
assert row['status'] == 'delivered'
assert row['message']['content'] == 'Research timed out before collecting evidence.'
assert '#research-' not in row['message']['content']
@pytest.mark.asyncio
async def test_unavailable_summary_model_cannot_stall_delivery_forever(store):
import asyncio
from src.background_tool_jobs import BackgroundToolJobs
async def hanging_model(*args): await asyncio.Future()
jobs = BackgroundToolJobs(is_busy=lambda sid: False, summarize=hanging_model, summary_timeout=0.01)
jobs.register('rp-timeout', 'origin', 'alice', 'research', 'query', 2)
jobs.complete('rp-timeout', 'The preserved source report.', [])
await jobs.tick()
row = jobs.list_for_chat('origin', 'alice')[0]
assert row['status'] == 'delivered'
assert 'could not generate' in row['message']['content']
assert '#research-rp-timeout' in row['message']['content']
+80
View File
@@ -0,0 +1,80 @@
"""Regression tests for tool selection on implicit retrieval requests."""
from src.agent_loop import _classify_agent_request, _minimal_native_tool_prompt
def domains(prompt: str) -> set[str]:
return set(_classify_agent_request([], prompt)["domains"])
def test_implicit_written_note_selects_notes_domain():
assert "notes_calendar_tasks" in domains(
"Find what I wrote down for roaster exhaust fan and say who to call if the noise persists."
)
def test_saved_memory_search_selects_memory_domain():
result = domains(
"Search saved memory for Harbor Guji delay and report its fixture target code."
)
assert "memory" in result
assert "web" not in result
def test_negated_workspace_reference_does_not_create_file_domain():
result = domains(
"Search saved memory for Harbor Guji delay. Do not use the workspace."
)
assert "memory" in result
assert "files" not in result
def test_past_chat_search_selects_sessions_domain():
assert "sessions" in domains(
"Search prior chats for Farmers market prep and report the exact Guji bag count."
)
def test_short_local_actions_do_not_take_casual_chat_path():
assert "files" in domains("run tests")
assert "files" in domains("read the README and summarize it")
assert "files" in domains("find ajax local IP")
assert "files" in domains("what is in my workspace?")
def test_local_data_filename_is_not_mistaken_for_a_web_domain():
result = domains("Write answer.json")
assert "web" not in result
assert "files" in result
assert "documents" not in result
def test_source_filename_selects_the_workspace_domain():
assert "files" in domains("Create app.ts")
assert "files" in domains("Edit src/app.py")
def test_short_casual_followup_stays_low_signal():
result = _classify_agent_request([], "test now")
assert result["low_signal"] is True
assert result["domains"] == set()
def test_short_ambiguous_fragment_is_not_a_tool_retrieval_request():
from src.agent_loop import _is_ambiguous_short_low_signal
assert _is_ambiguous_short_low_signal("sned links") is True
assert _is_ambiguous_short_low_signal("list my notes") is False
assert _is_ambiguous_short_low_signal("run tests") is False
def test_minimal_prompt_requires_memory_tool_for_memory_lookup():
prompt = _minimal_native_tool_prompt({"manage_memory"})
assert "manage_memory" in prompt
assert "injected memory context alone" in prompt
def test_minimal_prompt_requires_chat_search_for_past_chat_lookup():
prompt = _minimal_native_tool_prompt({"search_chats"})
assert "search_chats" in prompt
assert "past chat or conversation" in prompt
@@ -0,0 +1,25 @@
import asyncio
import json
import pytest
from src.agent_tools.web_tools import PrivateBrowserTool
@pytest.mark.parametrize("name", ["output.html", "source.svg", "report.pdf", "notes.txt"])
def test_screenshot_cannot_overwrite_nonimage_artifact(monkeypatch, tmp_path, name):
source = tmp_path / name
source.write_bytes(b"original artifact")
monkeypatch.setattr("src.tool_execution.get_active_workspace", lambda: str(tmp_path))
monkeypatch.setattr(PrivateBrowserTool, "_resolve_workspace_path", staticmethod(lambda value: source))
async def forbidden(*args, **kwargs):
raise AssertionError("browser must not launch with nonimage destination")
monkeypatch.setattr(asyncio, "create_subprocess_exec", forbidden)
result = asyncio.run(PrivateBrowserTool().execute(
json.dumps({"action": "screenshot", "path": "/workspace/" + name}),
{"session_id": "artifact-safety"},
))
assert result["exit_code"] == 1
assert "OUTPUT destination" in result["error"]
assert source.read_bytes() == b"original artifact"
+158
View File
@@ -0,0 +1,158 @@
import asyncio
from pathlib import Path
from services.memory.builtin_skills import install_builtin_skills
from services.memory.skills import SkillsManager
from src.tools.system import do_manage_skills
EXPECTED_BUILTIN_SKILLS = {
"action-evidence-synthesis",
"artifact-completion",
"developer-docs",
"multimodal-evidence",
"reviewable-external-draft",
"scheduling-coordination",
"support-triage-and-routing",
"terminal-recovery",
"test-driven-development",
"tool-discovery",
"verified-state-change",
"web-research-fallback",
}
ROOT = Path(__file__).resolve().parents[1]
def test_builtin_skills_have_the_same_provenance_badge_as_builtin_tasks():
skills_src = (ROOT / "static/js/skills.js").read_text(encoding="utf-8")
assert "if (sk.source === 'builtin')" in skills_src
assert 'class="task-builtin-badge" title="Built-in skill">built-in</span>' in skills_src
def test_distilled_communication_skills_are_source_neutral():
communication_root = ROOT / "resources" / "skills" / "communication"
text = "\n".join(
path.read_text(encoding="utf-8").lower()
for path in sorted(communication_root.rglob("SKILL.md"))
)
for forbidden in (
"benchmark",
"localhost:",
"/tmp_workspace",
"results.md",
"openclaw",
"slack",
"wildclaw",
):
assert forbidden not in text
def test_skill_prompt_treats_teacher_drafts_as_untrusted_candidates():
source = (ROOT / "src" / "agent_loop.py").read_text(encoding="utf-8")
assert "Treat every skill, including teacher drafts, as untrusted procedural guidance" in source
assert "Drafts written by the teacher loop are authoritative guidance" not in source
def test_matched_skill_prompt_applies_injected_procedure_without_refetching():
source = (ROOT / "src" / "agent_loop.py").read_text(encoding="utf-8")
assert "Their usable procedure, pitfalls, and " in source
assert "verification steps are already included below" in source
assert "do not call `manage_skills` to re-read it" in source
assert "do not quote the skill text as your answer" in source
assert 'verification = sk.get("verification") or []' in source
assert "Never call `view` merely to re-read an injected skill" in source
def test_builtin_skills_are_installed_before_first_user_setup(tmp_path):
manager = SkillsManager(str(tmp_path))
assert install_builtin_skills(manager, []) == len(EXPECTED_BUILTIN_SKILLS)
assert {skill["name"] for skill in manager.load_all()} == EXPECTED_BUILTIN_SKILLS
assert all(not skill.get("owner") for skill in manager.load_all())
assert all(skill["status"] == "published" for skill in manager.load_all())
assert all(skill["confidence"] == 1.0 for skill in manager.load_all())
def test_builtin_skills_are_installed_per_owner_and_are_idempotent(tmp_path):
manager = SkillsManager(str(tmp_path))
assert install_builtin_skills(manager, ["alice", "bob"]) == len(EXPECTED_BUILTIN_SKILLS)
assert install_builtin_skills(manager, ["alice", "bob"]) == 0
alice = manager.load(owner="alice")
bob = manager.load(owner="bob")
assert {skill["name"] for skill in alice} == EXPECTED_BUILTIN_SKILLS
assert {skill["name"] for skill in bob} == EXPECTED_BUILTIN_SKILLS
assert all(skill["source"] == "builtin" for skill in alice)
assert {skill["name"] for skill in manager.load(owner="carol")} == EXPECTED_BUILTIN_SKILLS
def test_agent_only_auto_promotes_oversized_svg_code_to_documents():
source = (ROOT / "src" / "agent_loop.py").read_text(encoding="utf-8")
assert "Auto-created document from" not in source
assert "Code ({doc_lang})" not in source
assert "_extract_oversized_svg(round_response)" in source
def test_oversized_svg_extraction_keeps_compact_visuals_inline():
from src.agent_loop import _extract_oversized_svg
compact = '<svg viewBox="0 0 720 360"><title>Compact</title></svg>'
large = (
'<svg viewBox="0 0 960 1000"><title>Large visual</title>\n'
+ "\n".join(f'<text y="{index}">line {index}</text>' for index in range(90))
+ "\n</svg>"
)
assert _extract_oversized_svg(compact) is None
assert _extract_oversized_svg(large) == large
def test_builtin_skills_are_not_assigned_by_legacy_owner_backfill(tmp_path):
manager = SkillsManager(str(tmp_path))
install_builtin_skills(manager, ["alice"])
assert manager.backfill_owner("alice", {"alice", "bob"}) == 0
assert manager.load(owner="bob")[0]["owner"] in (None, "")
def test_builtin_skill_progressive_view_is_visible_to_authenticated_owner(tmp_path, monkeypatch):
manager = SkillsManager(str(tmp_path))
install_builtin_skills(manager, ["alice"])
monkeypatch.setattr("src.constants.DATA_DIR", str(tmp_path))
result = asyncio.run(
do_manage_skills(
'{"action":"view","name":"test-driven-development"}',
owner="alice",
)
)
assert "error" not in result
assert "red-green-refactor" in result["results"].lower()
def test_builtin_skill_updates_are_synchronized_without_reinstall_count(tmp_path):
manager = SkillsManager(str(tmp_path))
install_builtin_skills(manager, [])
path = tmp_path / "skills" / "media" / "multimodal-evidence" / "SKILL.md"
stale = path.read_text(encoding="utf-8").replace(
"version: 1.0.1",
"version: 0.9.0",
).replace(
"Do not search binary office files with plain `grep` or `cat`.",
"Use any available command.",
)
path.write_text(stale, encoding="utf-8")
assert install_builtin_skills(manager, []) == 0
refreshed = path.read_text(encoding="utf-8")
assert "version: 1.0.1" in refreshed
assert "Do not search binary office files with plain `grep` or `cat`." in refreshed
@@ -0,0 +1,12 @@
from pathlib import Path
def test_builtin_installation_precedes_optional_auth_file_read():
source = (Path(__file__).parents[1] / "app.py").read_text(encoding="utf-8")
install_position = source.index("install_builtin_skills(skills_manager, ())")
owner_repair_position = source.index('with open(auth_path, encoding="utf-8") as f:', install_position)
assert install_position < owner_repair_position
between = source[install_position:owner_repair_position]
assert "Built-in skill installation skipped" in between
+35
View File
@@ -0,0 +1,35 @@
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
THEME_JS = (ROOT / "static/js/theme.js").read_text(encoding="utf-8")
def test_five_distinct_builtin_themes_are_available() -> None:
expected = {
"eclipse": "constellations",
"porcelain": "dots",
"arcade": "synapse",
"blueprint": "dots",
"monolith": "perlin-flow",
}
for name, pattern in expected.items():
assert f"{name}:" in THEME_JS
assert re.search(rf"\b{name}:\s*'{re.escape(pattern)}'", THEME_JS)
def test_new_themes_define_complete_core_palettes() -> None:
for name in ("eclipse", "porcelain", "arcade", "blueprint", "monolith"):
definition = THEME_JS.split(f"{name}:", 1)[1].split("},", 1)[0]
for color in ("bg", "fg", "panel", "border", "red"):
assert f"{color}:" in definition
def test_retired_theme_names_are_replaced() -> None:
for name in ("paper:", "copper:", "lavender:"):
assert not re.search(rf"^\s*{name}\s*\{{", THEME_JS, re.MULTILINE)
assert "paper: 'monolith'" in THEME_JS
assert "copper: 'arcade'" in THEME_JS
assert "lavender: 'porcelain'" in THEME_JS
+59
View File
@@ -0,0 +1,59 @@
"""Cache inventory failures must not become confident empty-inventory claims."""
import json
import httpx
import pytest
from src.tools.cookbook import do_list_cached_models
@pytest.mark.asyncio
async def test_failed_scan_is_not_reported_as_no_cached_models(monkeypatch):
from src import tool_implementations
monkeypatch.setattr(tool_implementations, '_internal_headers', lambda: {})
client = httpx.AsyncClient
def respond(request):
if request.url.path == '/api/cookbook/state':
return httpx.Response(200, json={'env': {'servers': []}, 'tasks': []})
return httpx.Response(503, json={'detail': 'unavailable'})
monkeypatch.setattr(httpx, 'AsyncClient', lambda **kwargs: client(
transport=httpx.MockTransport(respond), **kwargs))
result = await do_list_cached_models('{}', owner='alice')
assert result['exit_code'] == 1
assert result.get('scan_errors')
assert 'No cached models found' not in result.get('output', '')
@pytest.mark.asyncio
async def test_partial_scan_keeps_verified_models_but_marks_inventory_incomplete(monkeypatch):
from src import tool_implementations
monkeypatch.setattr(tool_implementations, '_internal_headers', lambda: {})
client = httpx.AsyncClient
def respond(request):
if request.url.path == '/api/cookbook/state':
return httpx.Response(200, json={'env': {'servers': [{'name': 'offline', 'host': 'offline.test'}]}})
if request.url.params.get('host'):
raise httpx.ReadTimeout('unavailable', request=request)
return httpx.Response(200, json={'models': [{'repo_id': 'fixture/model'}]})
monkeypatch.setattr(httpx, 'AsyncClient', lambda **kwargs: client(
transport=httpx.MockTransport(respond), **kwargs))
result = await do_list_cached_models('{}', owner='alice')
assert result['models'][0]['repo_id'] == 'fixture/model'
assert result['exit_code'] == 1
assert result['partial'] is True
assert result['scan_errors'] == [{'host': 'offline', 'reason': 'ReadTimeout'}]
@pytest.mark.asyncio
async def test_failed_server_discovery_does_not_claim_complete_local_only_inventory(monkeypatch):
from src import tool_implementations
monkeypatch.setattr(tool_implementations, '_internal_headers', lambda: {})
client = httpx.AsyncClient
def respond(request):
if request.url.path == '/api/cookbook/state':
return httpx.Response(503, json={'detail': 'unavailable'})
return httpx.Response(200, json={'models': [{'repo_id': 'fixture/local'}]})
monkeypatch.setattr(httpx, 'AsyncClient', lambda **kwargs: client(
transport=httpx.MockTransport(respond), **kwargs))
result = await do_list_cached_models('{}', owner='alice')
assert result['models'][0]['repo_id'] == 'fixture/local'
assert result['exit_code'] == 1 and result['partial']
+33 -1
View File
@@ -79,6 +79,39 @@ async def test_list_events_honors_range_aliases(start_key, end_key):
assert summaries == ["Late June planning"]
assert "between 2126-06-01 and 2126-07-01" in res["response"]
async def test_list_events_query_filters_when_explicit_range_is_present():
from src.tool_implementations import do_manage_calendar
owner = "calendar-query-" + uuid.uuid4().hex[:8]
marker = "ODY-EVAL-CALENDAR-" + uuid.uuid4().hex[:8]
matching = await do_manage_calendar(json.dumps({
"action": "create_event",
"summary": marker,
"dtstart": "2126-06-25T10:00:00Z",
"description": "temporary fixture",
}), owner=owner)
assert matching.get("exit_code", 0) == 0, matching
nonmatching = await do_manage_calendar(json.dumps({
"action": "create_event",
"summary": "Different fixture",
"dtstart": "2126-06-25T12:00:00Z",
}), owner=owner)
assert nonmatching.get("exit_code", 0) == 0, nonmatching
listed = await do_manage_calendar(json.dumps({
"action": "list_events",
"start": "2126-06-01",
"end": "2126-07-01",
"query": marker,
}), owner=owner)
assert listed.get("exit_code", 0) == 0, listed
assert [event["summary"] for event in listed["events"]] == [marker]
async def test_list_events_rejects_partial_loose_range():
from src.tool_implementations import do_manage_calendar
@@ -103,4 +136,3 @@ async def test_list_events_rejects_partial_loose_range():
assert res2.get("exit_code", 1) == 1, res2
assert "list_events needs explicit start/end" in res2.get("error", "")
+3 -1
View File
@@ -279,8 +279,10 @@ def test_list_events_filters_by_calendar_owner_before_output(monkeypatch):
expanded = []
def fake_expand(event, _start, _end):
def fake_expand(event, _start, _end, *, db=None, owner=None):
assert event.calendar.owner == "alice"
assert db is session
assert owner == "alice"
expanded.append(event.uid)
return [{"uid": event.uid, "dtstart": "2026-06-02T10:00:00"}]
+32
View File
@@ -225,6 +225,38 @@ def test_expand_monthly_all_day():
assert r["all_day"] is True
def test_expand_monthly_first_and_last_monday():
cal = import_calendar_routes()
ev = _make_event(
uid="evt-trash",
summary="Take out unburnable trash",
dtstart=datetime(2026, 8, 31, 8, 0),
dtend=datetime(2026, 8, 31, 8, 30),
rrule="FREQ=MONTHLY;BYDAY=1MO,-1MO",
)
results = cal._expand_rrule(ev, datetime(2026, 9, 1), datetime(2026, 11, 1))
dates = [r["dtstart"][:10] for r in results]
assert dates == ["2026-09-07", "2026-09-28", "2026-10-05", "2026-10-26"]
def test_expand_monthly_second_thursday_and_last_sunday():
cal = import_calendar_routes()
ev = _make_event(
uid="evt-maintenance",
summary="Maintenance",
dtstart=datetime(2026, 9, 10, 8, 0),
dtend=datetime(2026, 9, 10, 8, 30),
rrule="FREQ=MONTHLY;BYDAY=2TH,-1SU",
)
results = cal._expand_rrule(ev, datetime(2026, 9, 1), datetime(2026, 11, 1))
dates = [r["dtstart"][:10] for r in results]
assert dates == ["2026-09-10", "2026-09-27", "2026-10-08", "2026-10-25"]
def test_expand_bad_rrule_graceful():
"""Malformed rrule should fall back to returning the base event,
but only when the base event overlaps the requested window."""
@@ -61,12 +61,15 @@ async def test_reminder_minutes_accepts_abbreviations(reminder, expected):
res = await _create_with_reminder(reminder, owner)
assert res.get("exit_code") == 0, res
assert f"reminder {expected} min before" in res.get("response", ""), res
assert res.get("reminder_note_id"), res
assert res.get("reminder_minutes") == expected, res
assert res.get("dtstart") == "2030-01-01T10:00:00", res
db = _TS()
try:
note = (
db.query(Note)
.filter(Note.owner == owner, Note.title == "Reminder: Dentist")
.filter(Note.owner == owner, Note.title == "Calendar reminder: Dentist")
.first()
)
assert note is not None, "reminder note should have been created"
@@ -86,3 +89,37 @@ async def test_no_reminder_when_offset_absent():
res = await do_manage_calendar(json.dumps(payload), owner=owner)
assert res.get("exit_code") == 0, res
assert "reminder set" not in res.get("response", ""), res
async def test_update_event_can_add_reminder_after_creation():
owner = "tester-" + uuid.uuid4().hex[:6]
from src.tool_implementations import do_manage_calendar
created = await do_manage_calendar(json.dumps({
"action": "create_event",
"summary": "Kindergarten pickup",
"dtstart": "2030-03-01T15:00:00",
}), owner=owner)
assert created.get("exit_code") == 0, created
updated = await do_manage_calendar(json.dumps({
"action": "update_event",
"uid": created["uid"],
"reminder_minutes": 15,
}), owner=owner)
assert updated.get("exit_code") == 0, updated
assert "reminder set 15 min before" in updated.get("response", ""), updated
assert updated.get("reminder_note_id")
assert updated.get("reminder_minutes") == 15, updated
assert updated.get("dtstart") == "2030-03-01T15:00:00", updated
db = _TS()
try:
note = (
db.query(Note)
.filter(Note.owner == owner, Note.title == "Calendar reminder: Kindergarten pickup")
.first()
)
assert note is not None, "update_event should create the reminder note"
finally:
db.close()
+80 -1
View File
@@ -8,6 +8,7 @@ calling do_manage_calendar with an rrule stores a single event carrying that RRU
import json
import sys
import uuid
from datetime import datetime
import pytest
@@ -17,7 +18,7 @@ from tests.helpers.sqlite_db import make_temp_sqlite
clear_fake_database_modules()
import core.database as cdb
from core.database import CalendarEvent
from core.database import CalendarCal, CalendarEvent, Note
_TS, _ENGINE, _TMPDB = make_temp_sqlite(cdb.Base.metadata)
@@ -60,6 +61,84 @@ async def test_create_event_with_rrule_persists_recurrence():
db.close()
def test_event_dict_exposes_note_backed_calendar_reminder():
from routes.calendar_routes import _event_to_dict
owner = "tester-" + uuid.uuid4().hex[:6]
db = _TS()
try:
cal = CalendarCal(id="cal-" + uuid.uuid4().hex[:6], owner=owner, name="Personal")
ev = CalendarEvent(
uid="event-" + uuid.uuid4().hex[:6],
calendar_id=cal.id,
summary="Take out trash",
dtstart=datetime(2026, 8, 31, 8, 0),
dtend=datetime(2026, 8, 31, 8, 30),
all_day=False,
)
note = Note(
id="note-" + uuid.uuid4().hex[:6],
owner=owner,
title="Calendar reminder: Take out trash",
items=json.dumps([{"text": "Take out trash — Mon Aug 31 08:00", "done": False}]),
note_type="todo",
label="calendar",
due_date="2026-08-31T07:45:00Z",
source="calendar",
archived=False,
)
db.add_all([cal, ev, note])
db.commit()
out = _event_to_dict(ev, db=db, owner=owner)
assert out["has_reminder"] is True
assert out["reminder_note_id"] == note.id
assert out["reminder_due_date"] == "2026-08-31T07:45:00Z"
assert out["reminder_minutes"] == 15
finally:
db.close()
def test_delete_calendar_reminders_removes_duplicate_legacy_rows():
from routes.calendar_routes import _delete_calendar_reminders_for_event
owner = "tester-" + uuid.uuid4().hex[:6]
db = _TS()
try:
cal = CalendarCal(id="cal-" + uuid.uuid4().hex[:6], owner=owner, name="Personal")
ev = CalendarEvent(
uid="event-" + uuid.uuid4().hex[:6],
calendar_id=cal.id,
summary="Pickup",
dtstart=datetime(2026, 8, 31, 8, 0),
dtend=datetime(2026, 8, 31, 8, 30),
all_day=False,
)
db.add(cal)
db.add(ev)
for title in ("Reminder: Pickup", "Calendar reminder: Pickup"):
db.add(Note(
id="note-" + uuid.uuid4().hex[:6],
owner=owner,
title=title,
items=json.dumps([{"text": "Pickup", "done": False}]),
note_type="todo",
label="calendar",
due_date="2026-08-31T07:45:00Z",
source="calendar",
archived=False,
))
db.commit()
assert _delete_calendar_reminders_for_event(db, owner, ev) == 2
db.commit()
remaining = db.query(Note).filter(Note.owner == owner, Note.source == "calendar").all()
assert remaining == []
finally:
db.close()
async def test_create_event_without_rrule_is_single():
from src.tool_implementations import do_manage_calendar
+86
View File
@@ -0,0 +1,86 @@
"""Calendar confirmation links through real storage, dispatcher and SSE output."""
import json
import re
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from core import database
from src.clean_agent_preview import stream_preview
from src.tool_policy import ToolPolicy
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
from src.turn_contract import resolve_full_inventory_contract
@pytest.mark.asyncio
@pytest.mark.parametrize('case', ['create', 'update', 'already_linked', 'failed_update'])
async def test_event_confirmation_stream_preserves_only_successful_links(tmp_path, monkeypatch, case):
import src.clean_agent_preview as preview
engine = create_engine(f'sqlite:///{tmp_path / "calendar.db"}')
database.Base.metadata.create_all(engine)
monkeypatch.setenv('AUTH_ENABLED', 'false')
monkeypatch.setattr(database, 'SessionLocal', sessionmaker(bind=engine))
from src.tools.calendar import do_manage_calendar
args = {'action': 'create_event', 'summary': 'Fixture vet', 'dtstart': '2030-01-01T09:00:00'}
answer = 'Done. Your vet appointment is scheduled.'
if case != 'create':
seed = await do_manage_calendar(json.dumps(args), owner='fixture-owner')
assert seed['exit_code'] == 0
args = {'action': 'update_event', 'uid': seed['uid'], 'summary': 'Fixture vet updated'}
if case == 'already_linked':
answer += f' [Vet](#event-{seed["uid"]})'
if case == 'failed_update':
args['uid'] = 'missing-event'
answer = 'I could not find that event.'
replies = iter([
{'tool_calls': [{'index': 0, 'id': 'create-1', 'function': {
'name': 'manage_calendar', 'arguments': json.dumps(args)}}]},
{'content': answer},
])
class Response:
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def raise_for_status(self): pass
async def aiter_lines(self):
yield 'data: ' + json.dumps({'choices': [{'delta': next(replies)}]})
yield 'data: [DONE]'
class Client:
def __init__(self, **kwargs): pass
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def stream(self, *args, **kwargs): return Response()
monkeypatch.setattr(preview.httpx, 'AsyncClient', Client)
schema = next(s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] == 'manage_calendar')
policy = ToolPolicy()
contract = resolve_full_inventory_contract(schemas=[schema], policy=policy)
try:
raw = [chunk async for chunk in stream_preview(
endpoint_url='http://model.test', model='test', headers={},
messages=[{'role': 'user', 'content': 'Add Fixture vet on January 1, 2030 at 9 AM.' if case == 'create'
else 'Update my calendar event title to Fixture vet updated.'}],
turn_contract=contract, session_id='fixture-links', owner='fixture-owner',
disabled_tools=set(), tool_policy=policy,
)]
events = [json.loads(chunk[6:]) for chunk in raw if '[DONE]' not in chunk]
tool = next(e for e in events if e.get('type') == 'tool_output')
assert tool['error'] == (case == 'failed_update')
saved = await do_manage_calendar(json.dumps({
'action': 'list_events', 'start': '2030-01-01', 'end': '2030-01-02',
}), owner='fixture-owner')
target = re.search(r'#event-([\w-]+)', saved['response']).group(0)
streamed = ''.join(e.get('delta', '') for e in events)
if case == 'failed_update':
assert '#event-' not in streamed
else:
assert streamed.count(f']({target})') == 1
assert streamed.startswith(answer)
assert not any(e.get('type') == 'final_response' for e in events)
metrics = next(e['data'] for e in events if e.get('type') == 'metrics')
assert metrics['clean_v3_turn'][-1]['content'] == streamed
assert raw[-1] == 'data: [DONE]\n\n'
finally:
engine.dispose()
+71
View File
@@ -0,0 +1,71 @@
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
def test_calendar_tool_cards_link_to_events_in_live_and_saved_renderers():
live_src = (ROOT / "static/js/chat.js").read_text()
renderer_src = (ROOT / "static/js/chatRenderer.js").read_text()
assert "renderToolIcon(json.tool, cmd, json)" in live_src
assert "_calendarEventUidFromToolData" in renderer_src
assert "_target('event', uid, 'Open calendar event')" in renderer_src
assert "`#${kind}-${safeId}`" in renderer_src
assert "CALENDAR_ICON" in renderer_src
def test_successful_calendar_tool_output_is_suppressed_in_live_and_saved_renderers():
for rel in ("static/js/chat.js", "static/js/chatRenderer.js"):
src = (ROOT / rel).read_text()
assert "manage_calendar" in src
assert "return true;" in src
def test_calendar_chat_event_links_fetch_uid_and_show_title_time():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
style_src = (ROOT / "static/style.css").read_text()
routes_src = (ROOT / "routes/calendar_routes.py").read_text()
app_src = (ROOT / "static/app.js").read_text()
renderer_src = (ROOT / "static/js/chatRenderer.js").read_text()
inbox_src = (ROOT / "static/js/emailInbox.js").read_text()
library_src = (ROOT / "static/js/emailLibrary.js").read_text()
assert '@router.get("/events/{uid}")' in routes_src
assert "async function _fetchEventByUid" in calendar_src
assert "/api/calendar/events/${encodeURIComponent(id)}" in calendar_src
assert "const ev = await _fetchEventByUid(targetStr);" in calendar_src
assert "await _fetchEvents(range[0], range[1], true);" in calendar_src
assert "_selectedDay = _ds(now);" in calendar_src
assert "_selectedDay = _ds(dt);" in calendar_src
assert "_selectedDay = new Date(dt.getFullYear()" not in calendar_src
assert "function _parseTitleTime" in calendar_src
assert "function _fmtTimeSeconds" in calendar_src
assert "function _applyPendingEventHighlight" in calendar_src
assert "cal-event-link-target" in calendar_src
assert "scrollIntoView({ behavior: 'smooth', block: 'center'" in calendar_src
assert "cal-event-link-target-flash" in style_src
versions = []
for src in (app_src, renderer_src, inbox_src, library_src):
match = re.search(r"calendar\.js\?v=([A-Za-z0-9_-]+)", src)
assert match
versions.append(match.group(1))
assert len(set(versions)) == 1
def test_recurring_occurrence_delete_does_not_fall_back_to_series_delete():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
routes_src = (ROOT / "routes/calendar_routes.py").read_text()
assert "const deleteOccurrenceOnly = scope === 'occurrence';" in calendar_src
assert "const scopeParam = scope === 'occurrence' ? '?scope=occurrence' : '';" in calendar_src
assert "Occurrence delete requires a recurring occurrence uid" in routes_src
def test_memory_used_pill_reuses_sidebar_brain_icon():
renderer_src = (ROOT / "static/js/chatRenderer.js").read_text()
assert "memory-used-pill-text" in renderer_src
assert "M12 5a3 3 0 1 0-5.997.125" in renderer_src
assert "M12 2a7 7 0 0 1 7 7" not in renderer_src
+88
View File
@@ -50,6 +50,7 @@ async def test_update_event_dtstart_anchored_to_user_tz(tokyo_offset):
"dtstart": naive,
}), owner=owner)
assert created.get("exit_code", 0) == 0, created
assert created["all_day"] is False
uid = created["uid"]
db = _TS()
@@ -66,6 +67,7 @@ async def test_update_event_dtstart_anchored_to_user_tz(tokyo_offset):
"dtstart": naive,
}), owner=owner)
assert updated.get("exit_code", 0) == 0, updated
assert updated["all_day"] is False
db = _TS()
try:
@@ -80,6 +82,92 @@ async def test_update_event_dtstart_anchored_to_user_tz(tokyo_offset):
db.close()
async def test_update_event_with_time_converts_all_day_event_to_timed(tokyo_offset):
from src.tool_implementations import do_manage_calendar
owner = "all-day-to-timed-" + uuid.uuid4().hex[:6]
created = await do_manage_calendar(json.dumps({
"action": "create_event",
"summary": "Summer festival",
"dtstart": "2026-08-28",
"all_day": True,
}), owner=owner)
assert created.get("exit_code", 0) == 0, created
assert created["all_day"] is True
updated = await do_manage_calendar(json.dumps({
"action": "update_event",
"uid": created["uid"],
"dtstart": "2026-08-28T15:30:00",
"dtend": "2026-08-28T16:30:00",
}), owner=owner)
assert updated.get("exit_code", 0) == 0, updated
assert updated["all_day"] is False
assert "has_reminder" in updated
assert updated["dtstart"] == "2026-08-28T06:30:00Z"
db = _TS()
try:
ev = db.query(CalendarEvent).filter(CalendarEvent.uid == created["uid"]).first()
assert bool(ev.all_day) is False
assert bool(ev.is_utc) is True
assert ev.dtstart.hour == 6
assert ev.dtstart.minute == 30
finally:
db.close()
async def test_create_all_day_date_preserves_literal_calendar_day(tokyo_offset):
from src.tool_implementations import do_manage_calendar
owner = "all-day-date-" + uuid.uuid4().hex[:6]
created = await do_manage_calendar(json.dumps({
"action": "create_event",
"summary": "My Birthday",
"dtstart": "2026-10-24",
"all_day": True,
"rrule": "FREQ=YEARLY",
}), owner=owner)
assert created.get("exit_code", 0) == 0, created
db = _TS()
try:
ev = db.query(CalendarEvent).filter(CalendarEvent.uid == created["uid"]).first()
assert bool(ev.all_day) is True
assert bool(ev.is_utc) is False
assert ev.dtstart.isoformat() == "2026-10-24T00:00:00"
assert ev.dtend.isoformat() == "2026-10-25T00:00:00"
finally:
db.close()
async def test_update_event_resolves_exact_title_when_model_sends_id(tokyo_offset):
from src.tool_implementations import do_manage_calendar
owner = "title-update-" + uuid.uuid4().hex[:6]
title = "Temporary calendar fixture"
created = await do_manage_calendar(json.dumps({
"action": "create_event",
"summary": title,
"dtstart": "2030-01-02T10:00",
"dtend": "2030-01-02T11:00",
}), owner=owner)
assert created.get("exit_code", 0) == 0, created
updated = await do_manage_calendar(json.dumps({
"action": "update_event",
"id": title,
"location": "Updated fixture location",
}), owner=owner)
assert updated.get("exit_code", 0) == 0, updated
db = _TS()
try:
event = db.query(CalendarEvent).filter(CalendarEvent.uid == created["uid"]).first()
assert event.location == "Updated fixture location"
finally:
db.close()
async def test_list_events_accepts_start_time_end_time_aliases():
from src.tool_implementations import do_manage_calendar
+414
View File
@@ -0,0 +1,414 @@
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
def test_calendar_week_view_has_overlap_lanes_and_live_ruler_hooks():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
style_src = (ROOT / "static/style.css").read_text()
assert "function _wkLayoutTimedEvents" in calendar_src
assert "--lane:${lane};--lane-count:${laneCount}" in calendar_src
assert "--wk-hour-px:${WEEK_HOUR_PX}px" in calendar_src
assert "cal-wk-now::after" in style_src
assert "content:'now'" in style_src
assert "repeating-linear-gradient(" in style_src
assert "calc((var(--lane, 0) * (100% / var(--lane-count, 1))) + 2px)" in style_src
def test_crowded_week_events_expand_left_to_reveal_full_title_on_hover():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
style_src = (ROOT / "static/style.css").read_text()
assert "const crowdedClass = laneCount > 1 ? ' cal-wk-block-crowded' : '';" in calendar_src
assert "--lane-right:${laneRight}%" in calendar_src
assert "function _layoutCrowdedWeekHover(body)" in calendar_src
assert "const leftBound = Math.max(columnsRect.left, 0);" in calendar_src
assert "const rightBound = Math.min(columnsRect.right, window.innerWidth);" in calendar_src
assert "leftSpace >= desiredWidth || leftSpace >= rightSpace" in calendar_src
assert "const probe = block.cloneNode(true);" in calendar_src
assert "const hoverHeight = Math.max(eventHeight, Math.ceil(probe.getBoundingClientRect().height));" in calendar_src
assert "block.style.setProperty('--hover-height', `${hoverHeight}px`);" in calendar_src
assert "function _fitWeekBlockVertically(block, body)" in calendar_src
assert "const topBound = Math.max(wrapRect.top, stickyBottom, viewportTop) + 3;" in calendar_src
assert "const bottomBound = Math.min(wrapRect.bottom, viewportBottom) - 4;" in calendar_src
assert "block.style.setProperty('--hover-fit-height'" in calendar_src
assert "block.style.setProperty('--hover-shift-y'" in calendar_src
assert "_layoutCrowdedWeekHover(body);" in calendar_src
hover_idx = style_src.index(".cal-wk-block.cal-wk-block-crowded:hover")
name_idx = style_src.index(".cal-wk-block.cal-wk-block-crowded:hover .cal-wk-block-name", hover_idx)
anchor_idx = style_src.index(".cal-wk-block.cal-wk-block-crowded.cal-wk-expand-left")
assert "left: auto;" in style_src[anchor_idx:hover_idx]
assert "right: calc(100% - var(--lane-right) + 2px);" in style_src[anchor_idx:hover_idx]
assert "width: var(--hover-width, 170px);" in style_src[hover_idx:name_idx]
assert "height: var(--hover-fit-height, var(--hover-height, var(--event-height))) !important;" in style_src[hover_idx:name_idx]
assert "height 0.2s cubic-bezier" in style_src
label_idx = style_src.index(".cal-wk-block.cal-wk-block-crowded:hover .cal-wk-block-label", hover_idx)
assert "position: absolute;" in style_src[label_idx:name_idx]
assert "width: calc(var(--hover-width, 170px) - 18px);" in style_src[label_idx:name_idx]
assert ".cal-wk-block.cal-wk-block-crowded.cal-wk-expand-left:hover .cal-wk-block-label" in style_src
assert ".cal-wk-block.cal-wk-block-crowded.cal-wk-expand-right:hover .cal-wk-block-label" in style_src
assert "@keyframes cal-wk-hover-label-reveal" in style_src
assert "z-index: 30;" in style_src[hover_idx:name_idx]
assert "white-space: normal;" in style_src[name_idx:name_idx + 260]
next_idx = calendar_src.index("document.getElementById('cal-next')?.addEventListener('click'")
render_idx = calendar_src.index("_render();", next_idx)
assert "block.classList.remove('cal-wk-block-crowded');" in calendar_src[next_idx:render_idx]
def test_mobile_truncated_week_event_expands_before_opening_editor():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
style_src = (ROOT / "static/style.css").read_text()
click_idx = calendar_src.index("body.querySelectorAll('.cal-wk-block, .cal-wk-allday-event')")
edit_idx = calendar_src.index("if (ev) _showEventForm(ev);", click_idx)
expand_idx = calendar_src.index("el.classList.add('cal-wk-mobile-expanded');", click_idx)
assert "window.matchMedia('(max-width: 768px)').matches" in calendar_src[click_idx:edit_idx]
assert "name.scrollHeight > name.clientHeight + 1" in calendar_src[click_idx:edit_idx]
assert expand_idx < edit_idx
assert "if (titleIsClipped && !el.classList.contains('cal-wk-mobile-expanded'))" in calendar_src
assert ".cal-wk-block.cal-wk-mobile-expanded" in style_src
assert "height: var(--hover-fit-height, var(--hover-height, var(--event-height))) !important;" in style_src[
style_src.index(".cal-wk-block.cal-wk-mobile-expanded"):
]
def test_mobile_empty_week_slot_selects_before_opening_new_event():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
style_src = (ROOT / "static/style.css").read_text()
assert "let _selectedWeekSlot = null;" in calendar_src
assert "function _paintSelectedWeekSlot(body)" in calendar_src
assert "function _clearSelectedWeekSlot(body)" in calendar_src
assert "window.matchMedia('(max-width: 768px)').matches && !dragged" in calendar_src
assert "Math.hypot(mv.clientX - startX, (mv.clientY - rect.top) - startY) > 5" in calendar_src
same_idx = calendar_src.index("const sameSlot = _selectedWeekSlot?.date === ds")
select_idx = calendar_src.index("_selectedWeekSlot = { date: ds, start: startHHMM, end: endHHMM };", same_idx)
open_idx = calendar_src.index("_showEventFormForRange(ds, startHHMM, endHHMM);", select_idx)
assert same_idx < select_idx < open_idx
assert "block.classList.remove('cal-wk-mobile-expanded');" in calendar_src[select_idx - 500:select_idx]
assert ".cal-wk-slot-selected" in style_src
assert "className = 'cal-wk-slot-resize';" in calendar_src
assert "resize.addEventListener('pointerdown'" in calendar_src
assert "_selectedWeekSlot.end = nextEnd;" in calendar_src
assert ".cal-wk-slot-resize::after" in style_src
assert ".cal-wk-col.cal-wk-slot-day-selected .cal-wk-col-head" in style_src
def test_week_view_hints_when_current_time_is_below_the_visible_field():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
style_src = (ROOT / "static/style.css").read_text()
assert "function _updateWeekNowBelowHint(wrap)" in calendar_src
assert "nowRect.top > wrapRect.bottom - 2" in calendar_src
assert "const todayColumn = nowLine.closest('.cal-wk-col');" in calendar_src
assert "hint.className = 'cal-wk-now-below-hint';" in calendar_src
assert "columnRect.left - wrapRect.left + wrap.scrollLeft" in calendar_src
assert "hint.style.width = `${columnRect.width}px`;" in calendar_src
assert "requestAnimationFrame(() => _updateWeekNowBelowHint(_wrap));" in calendar_src
hint_idx = style_src.index(".cal-wk-now-below-hint")
assert "background: var(--accent, var(--red));" in style_src[hint_idx:hint_idx + 240]
def test_mobile_week_scroll_has_resisted_edge_pull_feedback():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
style_src = (ROOT / "static/style.css").read_text()
assert "pullStartedAtTop = _wrap.scrollTop <= 1;" in calendar_src
assert "pullStartedAtBottom = _wrap.scrollTop >= maxScroll - 1;" in calendar_src
assert "const pullingTop = pullStartedAtTop && _wrap.scrollTop <= 1 && dy > 6;" in calendar_src
assert "const pullingBottom = pullStartedAtBottom && _wrap.scrollTop >= maxScroll - 1 && dy < -6;" in calendar_src
assert "Math.min(7, Math.abs(dy) * 0.1)" in calendar_src
assert "_wrap.addEventListener('touchcancel', releaseEdgePull" in calendar_src
assert ".cal-wk-wrap.cal-wk-edge-release > .cal-wk-cols" in style_src
assert "transform: translateY(var(--wk-edge-pull, 0));" in style_src
assert "-webkit-overflow-scrolling: touch;" in style_src
def test_calendar_view_change_recovers_a_fully_open_day_drawer():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
view_idx = calendar_src.index("body.querySelectorAll('.cal-view-btn')")
render_idx = calendar_src.index("_render();", view_idx)
handler = calendar_src[view_idx:render_idx]
assert "const drawerWasPinnedOpen" in handler
assert "calBody.classList.remove('cal-events-full', 'cal-calendar-full');" in handler
assert "localStorage.removeItem('odysseus.cal.detailH');" in handler
assert "localStorage.removeItem('odysseus.cal.splitSnap');" in handler
assert "_selectedDay = (_view === 'month' || _view === 'week') ? _ds(_currentDate) : null;" in handler
def test_calendar_month_and_agenda_have_visual_depth_hooks():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
style_src = (ROOT / "static/style.css").read_text()
assert "cal-weekend" in calendar_src
assert "cal-empty-day" in calendar_src
assert ".cal-day.cal-weekend:not(.cal-today)" in style_src
assert ".cal-day.cal-empty-day::after" in style_src
assert ".cal-agenda-day::before" in style_src
assert ".cal-agenda-day::after" in style_src
assert "cal-event-enter" in style_src
def test_calendar_event_cards_show_compact_source_badges():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
style_src = (ROOT / "static/style.css").read_text()
assert "function _eventSourceHtml" in calendar_src
assert "${_eventSourceHtml(ev)}" in calendar_src
assert "${_eventSourceHtml(md)}" in calendar_src
assert ".cal-event-source" in style_src
def test_calendar_toolbar_previous_next_arrows_are_mobile_only():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
style_src = (ROOT / "static/style.css").read_text()
assert 'class="cal-nav cal-toolbar-arrow" id="cal-prev"' in calendar_src
assert 'class="cal-nav cal-toolbar-arrow" id="cal-next"' in calendar_src
base_idx = style_src.index("button.cal-nav.cal-toolbar-arrow {")
hidden_idx = style_src.index("display: none;", base_idx)
mobile_idx = style_src.index("@media (max-width: 768px)", hidden_idx)
shown_idx = style_src.index("button.cal-nav.cal-toolbar-arrow { display: inline-flex !important; }", mobile_idx)
assert base_idx < hidden_idx < mobile_idx < shown_idx
assert "#cal-next { margin-left: auto; }" in style_src
assert "#cal-add { margin-left: auto; }" in style_src
def test_calendar_side_arrows_center_against_calendar_pane():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
style_src = (ROOT / "static/style.css").read_text()
assert "function _alignSideNavToCalendar()" in calendar_src
assert "paneRect.top - contentRect.top + (paneRect.height / 2)" in calendar_src
assert "new ResizeObserver(_alignSideNavToCalendar)" in calendar_src
assert "top: var(--cal-side-nav-y, 50%);" in style_src
def test_calendar_splitter_double_click_snaps_to_nearest_pane_then_toggles():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
style_src = (ROOT / "static/style.css").read_text()
assert "detailH >= calendarH ? 'events' : 'calendar'" in calendar_src
assert "splitter.addEventListener('dblclick', () => {" in calendar_src
assert "Date.now() - _lastTouchSnap < 500" in calendar_src
assert "calBody.classList.contains(eventsFullClass)" in calendar_src
assert "calBody.classList.contains(calendarFullClass)" in calendar_src
assert "#cal-body.cal-events-full > :is(.cal-grid, .cal-wk-wrap)" in style_src
assert "#cal-body.cal-calendar-full > .cal-day-detail" in style_src
def test_day_selection_adjusts_drawer_for_event_count():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
assert "function _adjustDayDrawerForSelection(body, dateStr)" in calendar_src
assert "if (!events.length)" in calendar_src
assert "body.classList.add('cal-calendar-full');" in calendar_src
assert "localStorage.setItem('odysseus.cal.splitSnap', 'calendar');" in calendar_src
assert "body.classList.contains('cal-calendar-full')" in calendar_src
assert "detail.getBoundingClientRect().height <= 48" in calendar_src
assert "const eventRows = Math.min(events.length, 3);" in calendar_src
assert "localStorage.removeItem('odysseus.cal.splitSnap');" in calendar_src
day_click_idx = calendar_src.index("body.querySelectorAll('.cal-day[data-date]')")
adjust_idx = calendar_src.index("_adjustDayDrawerForSelection(body, d);", day_click_idx)
second_click_idx = calendar_src.index("if (_selectedDay === d && !drawerAdjusted)", adjust_idx)
render_idx = calendar_src.index("_render();", adjust_idx)
assert day_click_idx < adjust_idx < second_click_idx < render_idx
def test_calendar_week_drag_preserves_multiday_event_duration():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
assert "function _eventDurationMinutes(ev)" in calendar_src
drag_idx = calendar_src.index("body.querySelectorAll('.cal-wk-block').forEach")
duration_idx = calendar_src.index("const durationMin = _eventDurationMinutes(ev);", drag_idx)
update_idx = calendar_src.index("const newDtend = _addMinutesToLocalIso(newDtstart, durationMin);", duration_idx)
assert duration_idx < update_idx
assert "let durationMin = endMin0 - startMin0" not in calendar_src[drag_idx:update_idx]
def test_calendar_open_restores_minimized_before_open_guard():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
open_idx = calendar_src.index("function openCalendar(options = {})")
restore_idx = calendar_src.index("Modals.isRegistered('calendar-modal') && Modals.isMinimized('calendar-modal')", open_idx)
open_guard_idx = calendar_src.index("if (_open)", open_idx)
assert restore_idx < open_guard_idx
assert "function _restoreCalendarFromMinimized()" in calendar_src
assert "restoreFn: () => { _restoreCalendarFromMinimized(); }" in calendar_src
def test_calendar_targeted_open_preserves_requested_focus():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
assert "const preserveFocus = !!(options && options.preserveFocus)" in calendar_src
assert "if (!preserveFocus)" in calendar_src
assert "openCalendar({ preserveFocus: true });" in calendar_src
def test_calendar_event_form_can_manually_save_tags():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
assert 'id="cal-f-type"' in calendar_src
assert "existing?.event_type" in calendar_src
assert "event_type: document.getElementById('cal-f-type')?.value || ''" in calendar_src
assert "const typeColor = tag ? (_TYPE_PALETTE[tag] || _TYPE_PALETTE.other) : ''" in calendar_src
assert 'style="color:${_e(color)};"' in calendar_src
assert "_typeSel.style.borderColor = typeColor || 'var(--border)'" in calendar_src
def test_calendar_tool_guidance_preserves_manual_tags_on_unrelated_updates():
schema_src = (ROOT / "src/tool_schemas.py").read_text()
index_src = (ROOT / "src/tool_index.py").read_text()
assert "otherwise omit it so manually tagged events keep their existing tag" in schema_src
assert "omitting preserves manual tags" in schema_src
assert "otherwise omit it so manually tagged events keep their existing tag" in index_src
def test_calendar_visual_asset_versions_are_bumped():
versions = []
for rel in (
"static/app.js",
"static/js/chatRenderer.js",
"static/js/emailInbox.js",
"static/js/emailLibrary.js",
):
src = (ROOT / rel).read_text()
match = re.search(r"calendar\.js\?v=([A-Za-z0-9_-]+)", src)
assert match
versions.append(match.group(1))
assert len(set(versions)) == 1
index_src = (ROOT / "static/index.html").read_text()
app_versions = re.findall(r"/static/app\.js\?v=([A-Za-z0-9_-]+)", index_src)
assert app_versions
assert len(set(app_versions)) == 1
def test_calendar_settings_are_close_only_and_color_the_name_field():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
app_src = (ROOT / "static/app.js").read_text()
assert "overlay.dataset.noMinimize = 'true'" in calendar_src
assert "modal.dataset.noMinimize === 'true'" in app_src
row_idx = calendar_src.index('class="cal-settings-row"')
name_idx = calendar_src.index('class="cal-s-name"', row_idx)
color_idx = calendar_src.index('class="cal-s-color"', row_idx)
assert name_idx < color_idx
assert "nameInput.style.borderColor = colorInput.value" in calendar_src
assert "nameInput.style.backgroundColor = colorInput.value" not in calendar_src
style_src = (ROOT / "static/style.css").read_text()
toggle_idx = style_src.index(".cal-week-start-toggle")
button_idx = style_src.index(".cal-week-start-btn", toggle_idx)
assert "padding: 0;" in style_src[toggle_idx:button_idx]
assert "height: 24px;" in style_src[toggle_idx:button_idx]
assert "height: 22px !important;" in style_src[button_idx:button_idx + 300]
def test_calendar_settings_actions_sync_error_and_escape_behavior():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
style_src = (ROOT / "static/style.css").read_text()
assert calendar_src.count('class="cal-settings-actions"') >= 4
assert "justify-content: flex-end;" in style_src[style_src.index(".cal-settings-actions"):]
assert 'class="cal-settings-sync-status"' in calendar_src
assert "status.classList.add('is-error')" in calendar_src
assert ".cal-settings-sync-status.is-error" in style_src
assert "color: var(--red);" in style_src[style_src.index(".cal-settings-sync-status.is-error"):]
assert "top: 2px;" in style_src[style_src.index(".cal-settings-sync-status"):]
assert "e.stopImmediatePropagation();" in calendar_src
assert "document.addEventListener('keydown', onSettingsKeydown, true);" in calendar_src
assert '<span style="position:relative;top:0;">Sync now</span>' in calendar_src
assert calendar_src.index("Upload a .ics file") < calendar_src.index('id="cal-import-file"')
assert calendar_src.index("Download a calendar as .ics") < calendar_src.index('class="memory-toolbar-btn cal-s-export-chip"')
assert calendar_src.index("Pulls events from your CalDAV server") < calendar_src.index('id="cal-settings-sync-now"')
def test_calendar_hides_navigation_in_agenda_and_event_forms():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
style_src = (ROOT / "static/style.css").read_text()
assert "_modal?.classList.toggle('cal-navigation-hidden', _view === 'agenda')" in calendar_src
assert "_modal?.classList.add('cal-navigation-hidden')" in calendar_src
assert "${_view === 'agenda' ? '' : '<button class=\"cal-nav cal-toolbar-arrow\" id=\"cal-prev\"" in calendar_src
assert "${_view === 'agenda' ? '' : '<button class=\"cal-nav cal-toolbar-arrow\" id=\"cal-next\"" in calendar_src
assert "_view !== 'agenda' && !document.querySelector('.cal-form')" in calendar_src
assert ".cal-navigation-hidden .cal-side-nav" in style_src
def test_calendar_event_form_escape_cancels_before_modal_close():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
ui_src = (ROOT / "static/js/ui.js").read_text()
form_guard = ui_src.index("const calendarEventForm = calendarModal?.querySelector('.cal-form');")
hovered_close = ui_src.index("if (_closeHoveredWindow())", form_guard)
assert form_guard < hovered_close
assert "calendarEventForm.querySelector('#cal-f-cancel, #cal-form-mobile-cancel')" in ui_src
assert "e.stopImmediatePropagation();" in calendar_src[calendar_src.index("if (document.querySelector('.cal-form'))"):]
def test_calendar_from_and_to_month_day_use_theme_highlight():
style_src = (ROOT / "static/style.css").read_text()
month_idx = style_src.index('input[type="date"]::-webkit-datetime-edit-month-field')
day_idx = style_src.index('input[type="date"]::-webkit-datetime-edit-day-field', month_idx)
year_idx = style_src.index('input[type="date"]::-webkit-datetime-edit-year-field', day_idx)
assert "color: var(--accent, var(--red));" in style_src[month_idx:year_idx]
assert "color: var(--fg);" in style_src[year_idx:year_idx + 180]
def test_calendar_location_hint_uses_accent_map_pin():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
style_src = (ROOT / "static/style.css").read_text()
assert 'class="cal-loc-hint"' in calendar_src
assert '<span>Location</span>' in calendar_src
assert '.cal-loc-input-wrap > input:placeholder-shown ~ .cal-loc-hint' in style_src
pin_idx = style_src.index('.cal-loc-hint svg')
assert 'color: var(--accent, var(--red));' in style_src[pin_idx:pin_idx + 100]
def test_calendar_tag_order_prioritizes_personal_work_travel_admin():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
assert "const typeOrder = ['!', 'personal', 'work', 'travel', 'admin'" in calendar_src
assert "const types = ['', 'personal', 'work', 'travel', 'admin'" in calendar_src
def test_long_week_events_keep_their_label_visible_while_scrolling():
calendar_src = (ROOT / "static/js/calendar.js").read_text()
style_src = (ROOT / "static/style.css").read_text()
assert 'class="cal-wk-block-label"' in calendar_src
label_idx = style_src.index(".cal-wk-block-label")
assert "position: sticky;" in style_src[label_idx:label_idx + 220]
assert "top: 37px;" in style_src[label_idx:label_idx + 220]
has_allday_idx = style_src.index(".cal-wk-has-allday .cal-wk-block-label")
assert "top: 61px;" in style_src[has_allday_idx:has_allday_idx + 120]
block_idx = style_src.index(".cal-wk-block {")
assert "overflow: clip;" in style_src[block_idx:label_idx]
head_idx = style_src.index(".cal-wk-col-head {")
allday_idx = style_src.rindex(
".cal-wk-allday {", 0, style_src.index(".cal-wk-allday::-webkit-scrollbar")
)
now_idx = style_src.index(".cal-wk-now {")
assert "z-index: 40;" in style_src[head_idx:head_idx + 500]
assert "z-index: 39;" in style_src[allday_idx:allday_idx + 500]
assert "z-index: 7;" in style_src[now_idx:now_idx + 500]
spacer_idx = style_src.index(".cal-wk-rail-spacer {")
allday_spacer_idx = style_src.index(".cal-wk-rail-allday-spacer {")
assert "height: 32px;" in style_src[spacer_idx:allday_spacer_idx]
assert "height: 24px;" in style_src[allday_spacer_idx:allday_spacer_idx + 300]
assert "weekHasAllDayEvents" in calendar_src
assert 'class="cal-wk-rail-allday-spacer"' in calendar_src
assert "weekHasAllDayEvents ? ' cal-wk-has-allday' : ''" in calendar_src
zoom_idx = style_src.index(".cal-wk-zoom {", allday_spacer_idx)
assert "top: -3px;" in style_src[zoom_idx:zoom_idx + 180]
assert "border-radius: 50%;" in style_src[zoom_idx:zoom_idx + 320]
settings_icon_idx = style_src.index("#cal-settings > svg {")
assert "top: 3px !important;" in style_src[settings_icon_idx:settings_icon_idx + 100]
+44
View File
@@ -0,0 +1,44 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
LIBRARY = (ROOT / "static/js/documentLibrary.js").read_text(encoding="utf-8")
def test_card_kebab_menus_share_font_rows_and_hover_behavior():
marker = "/* One visual contract for card kebab menus."
contract = STYLE.split(marker, 1)[1].split(
"/* Email modal title unread badge", 1
)[0]
for selector in (
".session-dropdown-menu",
".email-card-dropdown",
".memory-item-dropdown",
".task-dropdown",
".skill-kebab-menu",
".doclib-card-dropdown",
):
assert selector in contract
assert "font-family: inherit !important" in contract
assert "font: inherit !important" in contract
assert "transform: none" in contract
def test_library_chat_card_menu_uses_standard_anchor_gap():
assert "dd.style.top = (rect.bottom + 4) + 'px'" in LIBRARY
def test_card_menus_use_the_same_anchor_gap():
for relative_path in (
"static/js/sessions.js",
"static/js/documentLibrary.js",
"static/js/emailLibrary.js",
"static/js/memory.js",
"static/js/tasks.js",
"static/js/skills.js",
):
source = (ROOT / relative_path).read_text(encoding="utf-8")
assert "rect.bottom + 2" not in source, relative_path
assert "r.bottom + 2" not in source, relative_path
@@ -0,0 +1,67 @@
import re
from pathlib import Path
CHAT_JS = Path("static/js/chat.js")
CHAT_STREAM_JS = Path("static/js/chatStream.js")
def _source(path: Path) -> str:
return path.read_text(encoding="utf-8")
def test_background_stream_render_helper_refuses_visible_dom_for_other_session():
source = _source(CHAT_JS)
render_match = re.search(r"(?m)^\s*_renderStream\s*=\s*\([^)]*\)\s*=>\s*\{", source)
assert render_match, "expected _renderStream arrow assignment"
render_start = render_match.start()
render_prefix = source[render_start : render_start + 420]
assert "sessionModule.getCurrentSessionId() !== streamSessionId" in render_prefix
assert "return;" in render_prefix
assert "!roundHolder || !roundHolder.isConnected" in render_prefix
def test_generated_images_are_session_scoped_during_streaming():
source = _source(CHAT_JS)
helper_start = source.index(" function _appendGeneratedImageBubble(data, sessionId = null)")
helper_prefix = source[helper_start : helper_start + 700]
assert "targetSessionId" in helper_prefix
assert "sessionModule.getCurrentSessionId() !== targetSessionId" in helper_prefix
assert "return false;" in helper_prefix
calls = re.findall(r"_appendGeneratedImageBubble\(([^\n;]+)\)", source)
assert calls, "expected generated-image bubble call sites"
unsafe_calls = [call for call in calls if "streamSessionId" not in call and not call.strip().startswith("data, sessionId")]
assert unsafe_calls == []
def test_background_completion_toast_does_not_insert_into_other_chat():
source = _source(CHAT_STREAM_JS)
fn_start = source.index("export function insertStreamDoneToast(sessionId, query)")
fn_prefix = source[fn_start : fn_start + 520]
assert "sessionModule.getCurrentSessionId() !== sessionId" in fn_prefix
assert "uiModule.showToast" in fn_prefix
guard_pos = fn_prefix.index("sessionModule.getCurrentSessionId() !== sessionId")
append_pos = fn_prefix.index("document.getElementById('chat-history')")
assert guard_pos < append_pos
def test_background_final_and_catch_paths_do_not_cleanup_visible_chat():
source = _source(CHAT_JS)
final_start = source.index(" // --- Final render (skip if stream was ever backgrounded")
final_block = source[final_start : final_start + 900]
assert "const _isBgFinal" in final_block
assert "if (!_isBgFinal) {" in final_block
assert final_block.index("if (!_isBgFinal) {") < final_block.index("_renderStream();")
assert "document.querySelectorAll('#chat-history .agent-thread.streaming')" in final_block
catch_start = source.index(" const _isBgCatch =")
catch_cleanup = source.index("document.querySelectorAll('#chat-history .agent-thread.streaming')", catch_start)
catch_block = source[catch_start : catch_cleanup + 160]
assert "if (!_isBgCatch) {" in catch_block
assert catch_block.index("if (!_isBgCatch) {") < catch_block.index("document.querySelectorAll")
assert "document.querySelectorAll('#chat-history .agent-thread.streaming')" in catch_block
+196
View File
@@ -6,6 +6,13 @@ from pathlib import Path
from types import SimpleNamespace
import pytest
def test_clean_repeated_assistant_content_collapses_concatenated_copies():
from routes.chat_helpers import clean_repeated_assistant_content
answer = "The second event starts at 1:00 AM."
assert clean_repeated_assistant_content(answer * 3) == answer
from fastapi import HTTPException
import routes.chat_helpers as chat_helpers
@@ -13,9 +20,12 @@ from routes.chat_helpers import (
_enforce_chat_privileges,
_session_is_research_spinoff,
auto_name_session,
auto_name_session_after_stream,
build_chat_context,
build_uploaded_file_manifest,
clean_repeated_assistant_content,
clean_thinking_for_save,
fallback_session_title,
needs_auto_name,
PreprocessedMessage,
PresetInfo,
@@ -23,6 +33,42 @@ from routes.chat_helpers import (
)
def test_clean_repeated_assistant_content_collapses_rejoined_calendar_answer():
answer = (
"Here's your calendar for the week:\n\n"
"- Monday -- Nothing planned\n"
"- Tuesday -- Rent payment\n\n"
"Nothing else comes up for the week."
)
dirty = (
"ls.\n</think>\n\n"
f"{answer}"
"ls.\n</think>\n\n"
f"{answer}\n"
f"{answer}"
)
assert clean_repeated_assistant_content(dirty) == answer
def test_clean_repeated_assistant_content_preserves_single_answer():
answer = "Here's your calendar for September:\n\n- Sep 4 -- Lunch with Jon"
assert clean_repeated_assistant_content(answer) == answer
def test_qwen_thinking_process_uses_last_explicit_final_marker():
leaked = (
"Thinking Process:\n\n1. Analyze the request.\n\n"
"Final Output Generation:\nDraft answer.\n\n"
"Wait, check the context again.\n\n"
"Final decision:\nThe preceding result says web access was disabled."
)
visible, metadata = clean_thinking_for_save(leaked)
assert visible == "The preceding result says web access was disabled."
assert metadata["thinking"].startswith("1. Analyze the request.")
class _AuthManager:
def __init__(self, privileges):
self._privileges = privileges
@@ -301,6 +347,40 @@ def test_needs_auto_name(name, expected):
assert needs_auto_name(name) == expected, f"needs_auto_name({name!r}) should be {expected}"
def test_auto_name_after_stream_reloads_session_before_naming(monkeypatch):
stale = SimpleNamespace(id="sid", name="kimi-k3 1:23:45 PM", history=[])
fresh = SimpleNamespace(
id="sid",
name="kimi-k3 1:23:45 PM",
history=[SimpleNamespace(role="user", content="search the web for Richmond")],
)
calls = []
class Manager:
def get_session(self, session_id):
assert session_id == "sid"
return fresh
async def fake_auto_name(manager, sess):
calls.append(sess)
monkeypatch.setattr(chat_helpers, "_is_session_stream_active", lambda _sid: False)
monkeypatch.setattr(chat_helpers, "auto_name_session", fake_auto_name)
asyncio.run(auto_name_session_after_stream("sid", Manager(), stale))
assert calls == [fresh]
def test_fallback_session_title_uses_first_prompt_words():
assert (
fallback_session_title(
"Find the answer online and keep it concise: where does alaska the last frontier take place?"
)
== "Find the answer online and keep"
)
def test_clean_thinking_for_save_extracts_gemma4_thought_channel():
content, metadata = clean_thinking_for_save(
"<|channel>thought\ninternal reasoning<channel|>Final answer.",
@@ -342,6 +422,18 @@ def test_clean_thinking_for_save_extracts_thought_tag():
assert metadata["thinking"] == "internal reasoning"
def test_clean_thinking_for_save_preserves_interrupted_thinking_only_turn():
content, metadata = clean_thinking_for_save(
'<think time="2.4">partial reasoning before stop',
{"stopped": True, "model": "thinking-model"},
)
assert content == ""
assert metadata["thinking"] == "partial reasoning before stop"
assert metadata["thinking_time"] == "2.4"
assert metadata["thinking_interrupted"] is True
def test_save_assistant_response_incognito_does_not_mutate_session_history():
sess = _FakeSession("selected-model")
@@ -580,3 +672,107 @@ async def test_build_chat_context_keeps_cookie_user_owner_scope(monkeypatch):
"preface_owner": "bob",
"compact_owner": "bob",
}
async def _build_context_web_probe(monkeypatch, *, message, use_web, agent_mode):
captured = {"use_web": None}
async def fake_preprocess(chat_handler, message, att_ids, sess, **kwargs):
return PreprocessedMessage(
enhanced_message=message,
user_content=message,
text_for_context=message,
youtube_transcripts=[],
attachment_meta=[],
)
def fake_extract_preset(chat_handler, preset_id):
return PresetInfo(
temperature=0.7,
max_tokens=1024,
system_prompt=None,
character_name=None,
)
def fake_build_context_preface(**kwargs):
captured["use_web"] = kwargs["use_web"]
return [], [], []
async def fake_maybe_compact(sess, endpoint_url, model, messages, headers, owner=None):
return messages, 8192, False
monkeypatch.setattr(chat_helpers, "preprocess", fake_preprocess)
monkeypatch.setattr(chat_helpers, "extract_preset", fake_extract_preset)
monkeypatch.setattr(chat_helpers, "load_prefs_for_user", lambda owner: {"memory_enabled": True, "skills_enabled": True})
monkeypatch.setattr(chat_helpers, "_normalize_model_id_from_cache", lambda sess: None)
monkeypatch.setattr(chat_helpers, "normalize_model_id", lambda endpoint_url, model, **kwargs: None)
monkeypatch.setattr(chat_helpers, "maybe_compact", fake_maybe_compact)
monkeypatch.setattr(chat_helpers, "trim_for_context", lambda messages, context_length: messages)
import src.user_time as user_time
monkeypatch.setattr(
user_time,
"current_datetime_context_message",
lambda now_utc=None: {"role": "user", "content": "[Context - current date/time]"},
raising=False,
)
sess = SimpleNamespace(
endpoint_url="http://model.local/v1/chat/completions",
model="test-model",
headers={},
history=[],
messages=[],
)
sess.get_context_messages = lambda: list(sess.messages)
request = SimpleNamespace(state=SimpleNamespace(api_token=False, current_user="alice"))
await build_chat_context(
sess=sess,
request=request,
chat_handler=SimpleNamespace(),
chat_processor=SimpleNamespace(build_context_preface=fake_build_context_preface),
message=message,
session_id="session-1",
use_web=use_web,
incognito=True,
agent_mode=agent_mode,
)
return captured
@pytest.mark.asyncio
async def test_build_chat_context_treats_use_web_false_string_as_false(monkeypatch):
captured = await _build_context_web_probe(
monkeypatch,
message="latest apple news",
use_web="false",
agent_mode=False,
)
assert captured["use_web"] is False
@pytest.mark.asyncio
async def test_build_chat_context_skips_pre_web_for_agent_calendar_turn(monkeypatch):
captured = await _build_context_web_probe(
monkeypatch,
message="when is my next appointment",
use_web="true",
agent_mode=True,
)
assert captured["use_web"] is False
@pytest.mark.asyncio
async def test_build_chat_context_skips_pre_web_for_agent_notes_turn(monkeypatch):
captured = await _build_context_web_probe(
monkeypatch,
message="show my latest notes",
use_web="true",
agent_mode=True,
)
assert captured["use_web"] is False
+46
View File
@@ -0,0 +1,46 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_user_mode_pill_is_rendered_and_live_updated():
renderer = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8")
chat = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
styles = (ROOT / "static/style.css").read_text(encoding="utf-8")
routes = (ROOT / "routes/chat_routes.py").read_text(encoding="utf-8")
helpers = (ROOT / "routes/chat_helpers.py").read_text(encoding="utf-8")
assert "function userModePill(metadata)" in renderer
assert "export function setUserModePill" in renderer
assert "json.type === 'turn_mode'" in chat
assert "_bubbleMeta.interaction_mode = _bubbleMode" in chat
assert ".user-mode-pill" in styles
assert "USER_MODE_AGENT_ICON" in renderer
assert "USER_MODE_CHAT_ICON" in renderer
assert "pill.innerHTML = mode === 'agent' ? USER_MODE_AGENT_ICON : USER_MODE_CHAT_ICON" in renderer
assert "Agent mode" in renderer
assert "Chat mode" in renderer
assert ".user-mode-pill svg" in styles
assert "'type': 'turn_mode'" in routes
assert '"interaction_mode"] = interaction_mode' in helpers
def test_calendar_event_anchor_uses_all_day_label():
agent_loop = (ROOT / "src/agent_loop.py").read_text(encoding="utf-8")
assert 'bool(_calendar_args_for_anchor.get("all_day"))' in agent_loop
assert 'return "All day"' in agent_loop
def test_open_calendar_persists_hidden_context_snapshot():
agent_loop = (ROOT / "src/agent_loop.py").read_text(encoding="utf-8")
renderer = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8")
assert "def _calendar_open_panel_snapshot_command" in agent_loop
assert '"triggered_by": "ui_control open_panel calendar"' in agent_loop
assert '"context_only": True' in agent_loop
assert "ToolBlock(\"manage_calendar\", _calendar_snapshot_command)" in agent_loop
assert '"type": "tool_start", "tool": "manage_calendar"' in agent_loop
assert "rangeSummary" in renderer
assert "if (ev && ev.context_only) continue;" not in renderer
@@ -118,6 +118,32 @@ def test_pinned_memory_injection_is_capped_at_five():
assert len(processor._last_used_memories) == 5
def test_pinned_identity_auto_injection_uses_first_five_not_newest():
rows = [
{
"id": f"identity-{idx}",
"text": f"User identity fact {idx} email marker.",
"category": "identity",
"pinned": True,
"timestamp": idx,
}
for idx in range(10)
]
processor = _processor(rows)
processor.build_context_preface(
message="Who is the user?",
session=SimpleNamespace(),
use_rag=False,
use_memory=True,
)
assert [m["text"] for m in processor._last_used_memories] == [
f"User identity fact {idx} email marker."
for idx in range(5)
]
def test_total_memory_injection_is_capped_at_five_across_pinned_and_recalled():
rows = [
{
@@ -150,3 +176,30 @@ def test_total_memory_injection_is_capped_at_five_across_pinned_and_recalled():
assert len(processor._last_used_memories) <= 5
assert sum(1 for m in processor._last_used_memories if m["type"] == "pinned") == 4
def test_explicit_memory_management_skips_recall_injection():
rows = [{
"id": "identity",
"text": "User's name is Felix.",
"category": "identity",
"pinned": True,
"timestamp": 1,
}]
processor = _processor(rows)
preface, _, _ = processor.build_context_preface(
message="list my saved memories",
session=SimpleNamespace(),
use_rag=False,
use_memory=True,
)
assert "User's name is Felix." not in _context_text(preface)
assert processor._last_used_memories == []
def test_memory_management_detection_does_not_disable_normal_recall():
assert ChatProcessor._is_memory_management_request("find my saved memories")
assert ChatProcessor._is_memory_management_request("what do you remember about my nationality?")
assert not ChatProcessor._is_memory_management_request("Explain how memory allocation works")
+233 -10
View File
@@ -14,15 +14,71 @@ from pathlib import Path
import pytest
from src.action_intents import classify_tool_intent
from routes.chat_routes import _is_personal_data_search_without_web_target
from routes.chat_routes import _explicitly_denies_web_lookup
from routes.chat_routes import _contains_explicit_url_target
from routes.chat_routes import _is_explicit_browser_automation_request
from routes.chat_routes import _prefers_structured_document_tools
from routes.chat_routes import _has_recent_private_browser_success
from routes.chat_routes import _is_contextual_browser_followup
from src.tool_policy import (
WEB_ACCESS_TOOL_NAMES,
WEB_TOOL_NAMES,
is_web_search_explicitly_denied,
web_intent_may_enable_for_turn,
web_search_enabled_for_turn,
)
_CHAT_ROUTES = Path(__file__).resolve().parent.parent / "routes" / "chat_routes.py"
def test_personal_data_search_is_not_mistaken_for_web_search():
assert _is_personal_data_search_without_web_target(
"Search saved memory for the Harbor Guji delay."
)
assert _is_personal_data_search_without_web_target(
"Find the prior chat where we fixed the parser."
)
assert _is_personal_data_search_without_web_target(
"Search my previous chats for the Odysseus TUI parser fix."
)
assert _is_personal_data_search_without_web_target(
"Do I have any events today?"
)
assert not _is_personal_data_search_without_web_target(
"Search saved memory, then check the latest news online."
)
def test_workspace_artifact_paths_are_not_mistaken_for_public_urls():
assert not _contains_explicit_url_target("Save /workspace/output.md")
assert not _contains_explicit_url_target("Inspect /workspace/fixtures/video.mp4")
assert _contains_explicit_url_target("Open https://example.com/report")
assert _contains_explicit_url_target("Open example.com/report")
def test_plain_pdf_url_is_retrieval_not_browser_automation():
prompt = "Download and read https://arxiv.org/pdf/2311.08526"
assert _contains_explicit_url_target(prompt)
assert not _is_explicit_browser_automation_request(prompt)
assert _is_explicit_browser_automation_request(
"Open the page https://example.com/report and click the details link"
)
def test_external_paper_tables_prefer_structured_tools_over_shell():
assert _prefers_structured_document_tools(
'From the paper "Example Suite", merge Table 2 and Table 4.'
)
assert _prefers_structured_document_tools(
"Download and read https://arxiv.org/pdf/2311.08526"
)
assert not _prefers_structured_document_tools(
"Extract Table 2 from /workspace/fixtures/paper.pdf"
)
# ── Source-level guards ─────────────────────────────────────────
@@ -79,6 +135,15 @@ def test_allow_web_search_reads_from_body_as_fallback():
)
def test_personal_store_search_takes_precedence_over_generic_web_words():
source = _CHAT_ROUTES.read_text(encoding="utf-8")
assert "_explicit_personal_store_intent" in source
assert "_explicit_web_target" in source
assert "not _explicit_personal_store_intent or _explicit_web_target" in source
assert "and not _explicit_personal_store_intent" in source
def test_browser_form_followups_include_approval_and_send_phrases():
"""Short approval replies after a form/browser turn must keep browser tools available."""
source = _CHAT_ROUTES.read_text(encoding="utf-8")
@@ -93,7 +158,7 @@ def test_agent_loop_expands_browser_mcp_tools_from_connected_server():
source = (Path(__file__).resolve().parent.parent / "src" / "agent_loop.py").read_text(encoding="utf-8")
assert "def _expand_browser_mcp_tools" in source
assert "server_id\") == \"builtin_browser\"" in source
assert "_relevant_tools = _expand_browser_mcp_tools(_relevant_tools, mcp_mgr)" in source
assert "_relevant_tools = _expand_browser_mcp_tools(_relevant_tools, mcp_mgr, disabled_tools)" in source
def test_disabled_tools_respects_missing_vs_explicit_toggles():
@@ -111,6 +176,13 @@ def test_disabled_tools_respects_missing_vs_explicit_toggles():
assert "web_search_enabled_for_turn(allow_web_search, use_web)" in source, (
"web tools must be gated through the explicit per-turn web setting"
)
assert "web_intent_may_enable_for_turn(" in source, (
"prompt web intent must respect both caller and message-level denials"
)
agent_source = (Path(__file__).resolve().parent.parent / "src" / "agent_loop.py").read_text(encoding="utf-8")
assert "explicit web domain enabled private web tools" in agent_source
assert "elif forced_tools and (set(forced_tools) & WEB_TOOL_NAMES):" in agent_source
assert "disabled_tools.difference_update(WEB_TOOL_NAMES)" in agent_source
assert "disabled_tools.update(WEB_TOOL_NAMES)" in source, (
"disabled_tools must add web_search/web_fetch when web is not explicitly enabled"
)
@@ -119,12 +191,115 @@ def test_disabled_tools_respects_missing_vs_explicit_toggles():
)
def test_explicit_private_browser_workflow_survives_web_search_disabled():
source = _CHAT_ROUTES.read_text(encoding="utf-8")
assert "_explicit_private_browser_intent" in source
assert "_explicit_private_browser_intent = bool(re.search" in source
assert "if not (_explicit_browser_intent or _local_browser_render_intent):" in source
assert 'disabled_tools.add("private_browser")' in source
def test_clean_private_browser_warmth_requires_typed_success():
successful = type("Session", (), {"history": [{
"role": "assistant",
"metadata": {"tool_events": [{
"tool": "private_browser", "exit_code": 0, "error": False,
}]},
}]})()
failed = type("Session", (), {"history": [{
"role": "assistant",
"metadata": {"tool_events": [{
"tool": "private_browser", "exit_code": 1, "error": True,
}]},
}]})()
prose_only = type("Session", (), {"history": [{
"role": "assistant", "content": "I used private_browser",
}]})()
assert _has_recent_private_browser_success(successful)
assert not _has_recent_private_browser_success(failed)
assert not _has_recent_private_browser_success(prose_only)
def test_contextual_browser_followup_recognizes_current_page_inspection():
session = type("Session", (), {"history": [{
"role": "user",
"content": "Browse https://example.com and take a snapshot.",
}]})()
assert _is_contextual_browser_followup(
"What heading is visible on that page? Check the current page before answering.",
session,
)
assert not _is_contextual_browser_followup("Show my notes.", session)
def test_clean_browser_filter_preserves_native_pdf_extraction_contract():
source = _CHAT_ROUTES.read_text()
assert "{'private_browser'} | NATIVE_WORKSPACE_TOOLS" in source
def test_clean_preview_only_offers_browser_for_explicit_or_typed_warm_turns():
source = _CHAT_ROUTES.read_text(encoding="utf-8")
assert "_has_recent_private_browser_success(sess)" in source
assert "if _explicit_browser_intent:" in source
assert "tool_family(s['function']['name']) != 'search_browser'" in source
assert "elif not _clean_v3_private_browser_warm and not (" in source
assert "_native_workspace_contract and _local_browser_render_intent" in source
def test_site_navigation_forces_private_browser_with_search_enabled():
source = _CHAT_ROUTES.read_text(encoding="utf-8")
assert "visit|go\\s+to|navigate\\s+to" in source
assert 'set(_BROWSER_MCP_TOOLS) | {"private_browser"}' in source
def test_web_toggle_preserves_typed_calendar_and_notes_tools():
source = _CHAT_ROUTES.read_text(encoding="utf-8")
assert '"calendar": {"manage_calendar"}' in source
assert '"notes": {"manage_notes", "manage_tasks"}' in source
assert '_forced_tools.update(_typed_forced_tools)' in source
def test_local_html_render_is_separate_from_open_web_access():
source = _CHAT_ROUTES.read_text(encoding="utf-8")
assert "_local_media_needs_browser_render(message)" in source
assert "_native_runtime_requires_local_browser(client_runtime_context)" in source
assert "or _local_browser_render_intent" in source
def test_workspace_auto_escalation_keeps_shell_tools():
"""Workspace/shell auto-routing must not use the light typed-tool clamp."""
source = _CHAT_ROUTES.read_text(encoding="utf-8")
assert '_workspace_agent_intent = _tool_intent.category in {"shell", "workspace"}' in source
assert '_tool_intent.category in {"shell", "workspace"}' in source
assert '_native_workspace_contract = bool(' in source
assert "allow_bash = \"true\"" in source
assert "if auto_escalated and not _workspace_agent_intent:" in source
assert '_forced_tools.update({"bash", "ls", "manage_bg_jobs"})' in source
assert "if auto_escalated and not _workspace_agent_intent and not _use_turn_contract:" in source
def test_native_declared_input_files_establish_workspace_intent():
from routes.chat_routes import _native_context_has_workspace_inputs
assert _native_context_has_workspace_inputs({
"surface": "odysseus-native",
"terminal_agent": True,
"input_files": ["/workspace/fixtures/video.mp4"],
})
assert not _native_context_has_workspace_inputs({
"surface": "odysseus-native",
"terminal_agent": False,
"input_files": ["/workspace/fixtures/video.mp4"],
})
def test_chat_tool_privileges_use_effective_bearer_owner():
"""Bearer TUI/WebUI turns must not be denied as the synthetic ``api`` user."""
source = _CHAT_ROUTES.read_text(encoding="utf-8")
privilege_gate = source[source.index("# Enforce per-user privileges"):]
assert "_user = effective_user(request)" in privilege_gate
assert "_user = ctx.user" not in privilege_gate
# ── Functional tests of the disabled-tools logic ───────────────
@@ -138,6 +313,7 @@ def _build_disabled_tools(
can_use_browser=True,
explicit_web_intent=False,
global_disabled=None,
allowed_tools=None,
):
"""Replicate the disabled-tools logic from chat_stream for unit testing.
@@ -150,7 +326,7 @@ def _build_disabled_tools(
disabled_tools.add("bash")
search_enabled = web_search_enabled_for_turn(allow_web_search, use_web)
if is_web_search_explicitly_denied(allow_web_search) or not search_enabled:
disabled_tools.update(WEB_TOOL_NAMES)
disabled_tools.update(WEB_ACCESS_TOOL_NAMES)
if explicit_web_intent:
disabled_tools.update({
"bash", "python",
@@ -175,10 +351,26 @@ def _build_disabled_tools(
disabled_tools.add("builtin_browser")
if global_disabled and isinstance(global_disabled, list):
disabled_tools.update(global_disabled)
if allowed_tools:
known = {"manage_notes", "manage_documents", "manage_calendar", "manage_tasks", "manage_memory", "search_chats", "read_file", "bash", "ui_control", "app_api", "list_emails", "read_email"}
allowed = set(allowed_tools)
disabled_tools.update(known - allowed)
disabled_tools.difference_update(allowed)
return disabled_tools
def test_allowed_tools_allowlist_disables_off_list_tools():
disabled = _build_disabled_tools(
allow_bash="true",
allowed_tools=["manage_notes", "ask_user"],
)
assert "manage_notes" not in disabled
assert "bash" in disabled
assert "ui_control" in disabled
assert "manage_documents" in disabled
def test_json_body_allow_bash_true_enables_bash():
"""API caller sending {"allow_bash": true} gets bash enabled."""
disabled = _build_disabled_tools(allow_bash="true")
@@ -203,6 +395,8 @@ def test_json_body_allow_web_search_false_disables_web():
disabled = _build_disabled_tools(allow_web_search="false")
assert "web_search" in disabled
assert "web_fetch" in disabled
assert "private_browser" in disabled
assert "youtube_tool" in disabled
def test_chat_mode_use_web_true_enables_web():
@@ -219,12 +413,26 @@ def test_allow_web_search_false_wins_over_use_web_true():
assert "web_fetch" in disabled
@pytest.mark.parametrize("denial", [False, "false", "0", "off"])
def test_explicit_web_toggle_denial_cannot_be_overridden_by_prompt_intent(denial):
assert web_intent_may_enable_for_turn(denial) is False
def test_missing_web_toggle_can_be_inferred_from_prompt_intent():
assert web_intent_may_enable_for_turn(None) is True
assert web_intent_may_enable_for_turn(
None,
message_denies_lookup=True,
) is False
@pytest.mark.parametrize(
"message",
[
"please use web search for current CVEs",
"search the web for current CVEs",
"can you look up the latest docs",
"look this up and answer with sources",
],
)
def test_explicit_false_disables_web_despite_prompt_web_intent(message):
@@ -242,19 +450,23 @@ def test_explicit_false_disables_web_despite_prompt_web_intent(message):
assert "web_fetch" in disabled
def test_prompt_web_intent_does_not_enable_web_without_setting():
"""Prompt-derived web intent alone must not expose web tools."""
intent = classify_tool_intent("look up the latest docs")
def test_prompt_web_intent_enables_web_without_frontend_toggle():
"""Explicit search/web wording should expose private web tools in agent mode."""
intent = classify_tool_intent("look this up and answer with sources")
assert intent is not None
assert intent.category == "web"
disabled = _build_disabled_tools(
allow_web_search=None,
allow_web_search="true",
use_web=None,
explicit_web_intent=True,
)
assert "web_search" in disabled
assert "web_fetch" in disabled
assert "web_search" not in disabled
assert "web_fetch" not in disabled
def test_explicit_no_search_phrase_blocks_web_auto_enable():
assert _explicitly_denies_web_lookup("answer from memory only, do not search")
def test_admin_user_gets_bash_enabled_by_default():
@@ -336,6 +548,17 @@ def test_frontend_always_sends_explicit_allow_bash():
)
def test_frontend_generic_web_prose_does_not_grant_shell_authority():
source = _CHAT_JS.read_text(encoding="utf-8")
workspace_line = next(
line for line in source.splitlines()
if "const workspaceAgentIntent" in line
)
for generic in ("source", "system", "app", "change", "review", "server", "api"):
assert generic not in workspace_line, generic
assert "_explicitWorkspaceTarget" in workspace_line
def test_frontend_sends_explicit_allow_web_search_false_in_agent_mode():
"""chat.js must send allow_web_search=false when web toggle is off in agent mode."""
source = _CHAT_JS.read_text(encoding="utf-8")
+4 -1
View File
@@ -5,7 +5,10 @@ from pathlib import Path
def test_stream_render_helpers_are_visible_to_catch_block():
source = Path("static/js/chat.js").read_text(encoding="utf-8")
try_start = source.index(" try {\n // Re-enable auto-scroll")
catch_start = source.index(" } catch (err) {", try_start)
catch_start = source.index(
" } catch (err) {\n // If a Stop or timeout",
try_start,
)
outer_scope = source[:try_start]
try_body = source[try_start:catch_start]
+34
View File
@@ -14,6 +14,40 @@ def test_live_tool_screenshot_does_not_template_raw_sse_value():
assert 'details.innerHTML = `<summary>Screenshot</summary><img src="${json.screenshot}"' not in chat
def test_private_browser_preview_uses_sanitized_screenshot_src():
chat = (_REPO / "static" / "js" / "chat.js").read_text(encoding="utf-8")
assert "function _updatePrivateBrowserPreview(json" in chat
assert "_isPrivateBrowserTool(json.tool)" in chat
assert "private-browser-preview-img" in chat
assert "const screenshotSrc = chatRenderer.safeToolScreenshotSrc(json.screenshot)" in chat
assert "preview.img.src = screenshotSrc" in chat
assert "preview.root.innerHTML = json.screenshot" not in chat
def test_browser_screenshot_is_persisted_for_history_reload():
loop = (_REPO / "src" / "agent_loop.py").read_text(encoding="utf-8")
assert 'tool_output_data["screenshot"] = f"data:{img[\'mimeType\']};base64,{img[\'data\']}"' in loop
assert 'tool_event["screenshot"] = f"data:{img[\'mimeType\']};base64,{img[\'data\']}"' in loop
def test_restored_private_browser_preview_uses_module_escape():
renderer = (_REPO / "static" / "js" / "chatRenderer.js").read_text(encoding="utf-8")
assert "private-browser-preview-img" in renderer
assert "uiModule.esc(screenshotSrc)" in renderer
assert "uiModule.esc(_privateBrowserActionLabel(command))" in renderer
assert "${esc(screenshotSrc)}" not in renderer
def test_restored_generic_tool_screenshot_uses_module_escape():
renderer = (_REPO / "static" / "js" / "chatRenderer.js").read_text(encoding="utf-8")
assert '${uiModule.esc(screenshotSrc)}' in renderer
assert '${esc(screenshotSrc)}' not in renderer
def test_restored_tool_screenshot_uses_raster_data_url_whitelist():
renderer = (_REPO / "static" / "js" / "chatRenderer.js").read_text(encoding="utf-8")
+35
View File
@@ -0,0 +1,35 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CHAT = (ROOT / "static/js/chat.js").read_text()
RENDERER = (ROOT / "static/js/chatRenderer.js").read_text()
def test_processing_indicator_shows_live_ttft_timer():
assert "Processing request · ${elapsed.toFixed(1)}s" in CHAT
assert "_ttftDisplayTimer = setInterval(update, 100);" in CHAT
def test_ttft_stops_on_model_output_not_agent_prep_metadata():
assert "if (data && data !== '[DONE]') markFirstVisibleOutput();" not in CHAT
assert "typeof json.delta === 'string' && json.delta.length > 0" in CHAT
assert "json.type === 'final_response'" in CHAT
def test_measured_ttft_is_shown_in_message_stats():
assert "metrics.client_ttft = _clientTtftSeconds" in CHAT
assert "metrics.client_ttft ?? metrics.time_to_first_token" in RENDERER
assert '<span class="ctx-label">TTFT</span>' in RENDERER
def test_compact_footer_and_details_show_real_performance_counters():
assert "`${Number(tps).toFixed(2)} tok/s`" in RENDERER
assert "`${Number(ttft).toFixed(3)}s TTFT`" in RENDERER
assert "`${Number(injectedTokens).toLocaleString()} in`" in RENDERER
assert 'Input (all rounds)' in RENDERER
assert 'Injected (first request)' in RENDERER
assert 'Tool schemas' in RENDERER
assert 'Agent rounds' in RENDERER
assert 'Tool calls' in RENDERER
assert "metrics.tps_source === 'computed' ? 'Speed (wall)'" in RENDERER
@@ -0,0 +1,14 @@
from src.clean_agent_preview import compact_schemas
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
def test_compact_pdf_contract_explains_visual_figure_recovery():
schemas = {
item["function"]["name"]: item["function"]
for item in compact_schemas(FUNCTION_TOOL_SCHEMAS)
}
description = schemas["pdf_extract"]["description"]
assert "inspect_media" in description
assert "pages" in description
assert "figure" in description
+54
View File
@@ -0,0 +1,54 @@
from services.search import core
def test_short_distinctive_paper_name_uses_exact_metadata_before_providers(
monkeypatch,
tmp_path,
):
provider_calls = []
monkeypatch.setattr(core, "SEARCH_CACHE_DIR", tmp_path)
monkeypatch.setattr(core, "search_cache_index", {})
monkeypatch.setattr(core, "_record_query", lambda *args, **kwargs: None)
monkeypatch.setattr(
core,
"_get_search_settings",
lambda: {"search_provider": "searxng", "search_fallback_chain": []},
)
monkeypatch.setattr(core, "_build_provider_chain", lambda provider: [provider])
monkeypatch.setattr(
core,
"_call_provider",
lambda *args, **kwargs: provider_calls.append((args, kwargs)) or [],
)
monkeypatch.setattr(
core,
"_direct_scholarly_title_results",
lambda title, count: [{
"title": "Qwen2-VL: Enhancing Vision-Language Model Perception",
"url": "https://arxiv.org/abs/2409.12191",
"snippet": "Official paper.",
"source": "openalex",
}] if title == "Qwen2-VL" else [],
)
results = core.searxng_search_results(
"Qwen2-VL paper multimodal benchmarks Table 2 Table 4",
count=5,
)
table_results = core.searxng_search_results(
"Qwen2-VL Table 2 benchmark scores",
count=5,
)
assert provider_calls == []
assert results[0]["url"] == "https://arxiv.org/abs/2409.12191"
assert table_results[0]["url"] == "https://arxiv.org/abs/2409.12191"
def test_generic_short_paper_phrase_does_not_claim_exact_title_resolution():
assert core._scholarly_title_from_query(
"recent paper benchmark results"
) == ""
assert core._scholarly_title_from_query(
"well-known paper benchmark results"
) == ""
@@ -0,0 +1,22 @@
from src.clean_agent_preview import successful_duplicate_recovery_message
def test_duplicate_pdf_recovery_names_missing_artifacts_and_next_tools(tmp_path):
prompt = (
"Read /workspace/fixtures/paper.pdf and create "
"/workspace/results.csv and /workspace/chart.png"
)
message = successful_duplicate_recovery_message(
"pdf_extract",
"withheld for the next correction round",
prompt,
str(tmp_path),
{"url": "/workspace/fixtures/paper.pdf", "query": "Figure 5"},
)
assert "/workspace/results.csv" in message
assert "/workspace/chart.png" in message
assert "python or write_file" in message
assert "inspect_media" in message
assert "pages" in message
@@ -0,0 +1,30 @@
from src.clean_agent_preview import verified_declared_workspace_artifacts
from PIL import Image
def test_verified_artifacts_count_as_completed_workspace_mutation(tmp_path):
(tmp_path / "report.csv").write_text("name,value\na,1\n")
Image.new('RGB', (1, 1), 'white').save(tmp_path / 'chart.png')
prompt = "Create /workspace/report.csv and /workspace/chart.png"
assert verified_declared_workspace_artifacts(prompt, str(tmp_path))
def test_missing_or_undeclared_artifacts_do_not_prove_a_mutation(tmp_path):
(tmp_path / "report.csv").write_text("name,value\na,1\n")
assert not verified_declared_workspace_artifacts(
"Create /workspace/report.csv and /workspace/chart.png",
str(tmp_path),
)
assert not verified_declared_workspace_artifacts(
"Create the requested files",
str(tmp_path),
)
def test_placeholder_second_artifact_does_not_prove_completion(tmp_path):
(tmp_path / 'report.csv').write_text('name,value\na,1\n')
(tmp_path / 'chart.png').write_bytes(b'PNG')
assert not verified_declared_workspace_artifacts(
'Create /workspace/report.csv and /workspace/chart.png', str(tmp_path))
@@ -0,0 +1,100 @@
import json
import pytest
from src.clean_agent_preview import stream_preview
from src.tool_policy import ToolPolicy
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
from src.turn_contract import resolve_full_inventory_contract
@pytest.mark.asyncio
async def test_malformed_text_artifact_write_uses_one_bounded_raw_body_handoff(monkeypatch):
import src.clean_agent_preview as module
requests = []
responses = iter([
{'choices': [{'delta': {'tool_calls': [{
'index': 0, 'id': 'truncated-write', 'function': {
'name': 'write_file',
'arguments': '{"path": "/workspace/output.html"',
},
}]}}]},
{'choices': [{'delta': {
'content': '<!doctype html><html><body>route</body></html>',
}}]},
])
class Response:
def __init__(self, payload):
self.payload = payload
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
def raise_for_status(self):
pass
async def aiter_lines(self):
yield 'data: ' + json.dumps(self.payload)
yield 'data: [DONE]'
class Client:
def __init__(self, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
def stream(self, *args, **kwargs):
requests.append(kwargs['json'])
return Response(next(responses))
executed = []
async def execute(block, **kwargs):
executed.append(block)
return 'write_file', {'output': 'Wrote output.html', 'exit_code': 0}
monkeypatch.setattr(module.httpx, 'AsyncClient', Client)
monkeypatch.setattr(module, 'execute_tool_block', execute)
schema = next(
item for item in FUNCTION_TOOL_SCHEMAS
if item['function']['name'] == 'write_file'
)
contract = resolve_full_inventory_contract(schemas=[schema], policy=ToolPolicy())
raw = [chunk async for chunk in stream_preview(
endpoint_url='http://test', model='test',
messages=[{'role': 'user', 'content': 'Create /workspace/output.html.'}],
headers={}, turn_contract=contract, session_id='test', owner='test',
disabled_tools=set(), tool_policy=ToolPolicy(), workspace='/tmp/workspace',
client_runtime_context={
'surface': 'odysseus-native', 'terminal_agent': True,
'unattended_mode': True,
}, max_tokens=8192, max_rounds=4,
)]
events = [json.loads(chunk[6:]) for chunk in raw if '[DONE]' not in chunk]
assert len(requests) == 2
assert 'tools' not in requests[1]
assert requests[1]['max_tokens'] == 4096
assert len(executed) == 1
assert executed[0].tool_type == 'write_file'
assert executed[0].content == (
'/workspace/output.html\n'
'<!doctype html><html><body>route</body></html>'
)
assert any(event.get('type') == 'artifact_body_handoff' for event in events)
assert any(
event.get('type') == 'tool_output'
and event.get('tool') == 'write_file'
and not event.get('error')
for event in events
)
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
import importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location('clean_loop_experiment', Path(__file__).parents[1] / 'scripts/test_clean_tool_loop.py')
experiment = importlib.util.module_from_spec(spec)
spec.loader.exec_module(experiment)
def call(name, arguments):
return {'id': 'test-call', 'type': 'function', 'function': {'name': name, 'arguments': arguments}}
def test_stable_inventory_does_not_depend_on_spelling_or_history():
assert experiment.inventory('stable', 'mraket', []) == experiment.inventory('stable', 'calendar', [])
def test_web_permission_filters_inventory():
offered = experiment.inventory('stable', 'search', [], web=False)
names = {s['function']['name'] for s in offered}
assert not names & experiment.FAMILY_TOOLS['search_browser']
assert 'manage_notes' in names
def test_model_query_passes_unchanged():
class Recorder:
def execute(self, name, args):
return args
assert experiment.validated_execute(call('web_search', '{"query":"stock market today"}'), experiment.SCHEMAS, Recorder()) == {'query': 'stock market today'}
def test_denied_tool_cannot_execute():
class Fail:
def execute(self, *args):
raise AssertionError('must not dispatch')
assert 'error' in experiment.validated_execute(call('web_search', '{"query":"x"}'), [], Fail())
def test_bad_arguments_return_error_not_repair():
assert 'error' in experiment.validated_execute(call('manage_notes', '{"action":"made_up"}'), experiment.SCHEMAS, experiment.Sandbox())
def test_mutation_is_not_executed():
sandbox = experiment.Sandbox()
assert 'error' in sandbox.execute('manage_notes', {'action': 'delete', 'id': 'note-102'})
assert len(sandbox.execute('manage_notes', {'action': 'list'})['notes']) == 2
def test_v3_keeps_tested_hints():
browser = next(s for s in experiment.SCHEMAS if s['function']['name'] == 'private_browser')
assert browser['function'].get('description')
def test_trained_notes_view_action_returns_record():
result = experiment.validated_execute(call('manage_notes', '{"action":"view","id":"note-102"}'), experiment.SCHEMAS, experiment.Sandbox())
assert result['note']['id'] == 'note-102'
+172
View File
@@ -0,0 +1,172 @@
import base64
import io
from PIL import Image
from src.clean_agent_preview import (
NATIVE_WORKSPACE_TOOLS,
bounded_visual_result_blocks,
native_input_files_clause,
preview_call_allowed,
preview_http_timeout,
protocol_safe_tool_calls,
scope_preview_contract,
)
from src.tool_policy import ToolPolicy
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
from src.turn_contract import resolve_full_inventory_contract
def test_native_workspace_tools_require_native_authority():
arguments = {"path": "/workspace/input.png"}
assert not preview_call_allowed("inspect_media", arguments, "inspect the file")
assert preview_call_allowed(
"inspect_media",
arguments,
"inspect the file",
allow_native_workspace=True,
)
def test_native_workspace_contract_includes_pdf_extraction_for_document_tasks():
assert "pdf_extract" in NATIVE_WORKSPACE_TOOLS
def test_native_workspace_contract_includes_transcription_for_media_tasks():
assert "transcribe_media" in NATIVE_WORKSPACE_TOOLS
def test_native_input_files_are_named_in_trusted_runtime_clause():
clause = native_input_files_clause({
"surface": "odysseus-native",
"terminal_agent": True,
"input_files": ["/workspace/fixtures/tutorial.mp4"],
})
assert "/workspace/fixtures/tutorial.mp4" in clause
assert native_input_files_clause({"input_files": ["/etc/passwd"]}) == ""
def test_native_workspace_allows_scoped_write_and_python_only_when_enabled():
write = {"path": "/workspace/output.html", "content": "<html></html>"}
python = {"code": "1 + 1"}
assert not preview_call_allowed("write_file", write, "write the output")
assert not preview_call_allowed(
"python", python, "analyze the file", allow_execute_code=True
)
assert preview_call_allowed(
"write_file", write, "write the output", allow_native_workspace=True
)
assert preview_call_allowed(
"python",
python,
"analyze the file",
allow_execute_code=True,
allow_native_workspace=True,
)
def test_native_workspace_exposes_exact_edit_and_search_without_interactive_access():
for name, args in [
('edit_file', {'path': '/workspace/a.txt', 'old_string': 'alpha', 'new_string': 'beta'}),
('glob', {'pattern': '*.txt', 'path': '/workspace'}),
('grep', {'pattern': 'alpha', 'path': '/workspace'}),
]:
assert name in NATIVE_WORKSPACE_TOOLS
assert preview_call_allowed(name, args, 'Use the workspace tool', allow_native_workspace=True)
assert not preview_call_allowed(name, args, 'Use the workspace tool')
def test_native_followup_tool_floor_respects_explicit_denials():
names = {'read_file', 'edit_file', 'glob', 'grep'}
schemas = [s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] in names]
for policy, expected in [
(ToolPolicy(disabled_tools=frozenset({'edit_file'})), names - {'edit_file'}),
(ToolPolicy(block_all_tool_calls=True), set()),
]:
preview = resolve_full_inventory_contract(schemas=schemas, policy=policy)
routed = resolve_full_inventory_contract(schemas=[], policy=policy)
scoped = scope_preview_contract(preview, routed, set(), extra_tools=NATIVE_WORKSPACE_TOOLS)
assert scoped.offered == expected
def test_native_visual_evidence_packs_every_frame_within_three_images():
colors = [(240, 10, 10), (10, 240, 10), (10, 10, 240),
(240, 240, 10), (240, 10, 240)]
images = []
for color in colors:
buffer = io.BytesIO()
Image.new("RGB", (16, 8), color).save(buffer, "PNG")
images.append({
"mimeType": "image/png",
"data": base64.b64encode(buffer.getvalue()).decode("ascii"),
})
result = {"images": images, "frame_timestamps": list(range(len(images)))}
blocks = bounded_visual_result_blocks(result, max_images=3)
assert len(blocks) == 3
observed = set()
for block in blocks:
encoded = block["image_url"]["url"].split(",", 1)[1]
with Image.open(io.BytesIO(base64.b64decode(encoded))) as packed:
for pixel in packed.convert("RGB").getdata():
if pixel in colors:
observed.add(pixel)
assert observed == set(colors)
def test_native_multimodal_stream_gets_long_read_timeout_only_in_native_mode():
assert preview_http_timeout().read == 90
assert preview_http_timeout(native_workspace_enabled=True).read == 600
assert preview_http_timeout(native_workspace_enabled=True).connect == 10
def test_malformed_tool_arguments_are_safe_in_provider_history():
calls = [{
"id": "call-1",
"type": "function",
"function": {"name": "write_file", "arguments": '{"path":"/workspace/output.html"'},
}]
safe_calls = protocol_safe_tool_calls(calls)
assert safe_calls[0]["function"]["arguments"] == "{}"
assert calls[0]["function"]["arguments"].startswith('{"path"')
def test_native_artifact_scope_adds_only_local_browser():
names = {"write_file", "private_browser", "web_search", "web_fetch"}
schemas = [s for s in FUNCTION_TOOL_SCHEMAS if s["function"]["name"] in names]
preview = resolve_full_inventory_contract(schemas=schemas, policy=ToolPolicy())
routed = resolve_full_inventory_contract(
schemas=[s for s in schemas if s["function"]["name"] == "write_file"],
policy=ToolPolicy(),
)
scoped = scope_preview_contract(
preview, routed, {"shell_files"}, extra_tools={"private_browser"}
)
assert scoped.offered == {"write_file", "private_browser"}
assert "web_search" not in scoped.offered
assert "web_fetch" not in scoped.offered
def test_native_workspace_floor_keeps_pdf_extraction_without_public_web_tools():
names = {"inspect_media", "write_file", "python", "pdf_extract", "web_search"}
schemas = [s for s in FUNCTION_TOOL_SCHEMAS if s["function"]["name"] in names]
preview = resolve_full_inventory_contract(schemas=schemas, policy=ToolPolicy())
routed = resolve_full_inventory_contract(
schemas=[s for s in schemas if s["function"]["name"] == "write_file"],
policy=ToolPolicy(),
)
scoped = scope_preview_contract(
preview,
routed,
{"shell_files"},
extra_tools=NATIVE_WORKSPACE_TOOLS,
)
assert scoped.offered == {"inspect_media", "write_file", "python", "pdf_extract"}
assert "web_search" not in scoped.offered
+61
View File
@@ -0,0 +1,61 @@
from routes.chat_routes import (
_clean_v3_route_for_model,
_native_runtime_requires_local_browser,
_turn_contract_enabled,
)
def test_preheretic_model_owns_clean_route_independent_of_endpoint_alias():
assert _clean_v3_route_for_model("odysseus-qwen3.5-tools-pre-heretic")
def test_other_models_keep_regular_harness():
assert not _clean_v3_route_for_model("qwen35-9b-base")
assert not _clean_v3_route_for_model("")
def test_clean_v3_keeps_contract_ownership_on_native_workspace():
assert _turn_contract_enabled(
exact_tool_approval=None,
runtime_surface="odysseus-native",
native_workspace_contract=True,
clean_v3_route=True,
)
def test_other_models_keep_separate_native_workspace_contract():
assert not _turn_contract_enabled(
exact_tool_approval=None,
runtime_surface="odysseus-native",
native_workspace_contract=True,
clean_v3_route=False,
)
def test_tui_and_exact_approval_still_bypass_routed_contract():
assert not _turn_contract_enabled(
exact_tool_approval=None,
runtime_surface="odysseus-tui",
native_workspace_contract=False,
clean_v3_route=True,
)
def test_native_html_artifact_requires_local_browser_without_public_web():
context = {
"surface": "odysseus-native",
"terminal_agent": True,
"unattended_mode": True,
"completion_requirements": {
"required_artifacts": ["/workspace/output.html"],
},
}
assert _native_runtime_requires_local_browser(context)
context["completion_requirements"]["required_artifacts"] = ["/workspace/output.txt"]
assert not _native_runtime_requires_local_browser(context)
assert not _turn_contract_enabled(
exact_tool_approval=object(),
runtime_surface="",
native_workspace_contract=False,
clean_v3_route=True,
)
+50
View File
@@ -0,0 +1,50 @@
import json
from routes.chat_routes import _parse_client_runtime_context
from src.agent_loop import _TUI_BRIDGE_TOOL_NAMES
from src.client_tool_contract import (
TUI_CLIENT_TOOL_NAMES,
TUI_ROUTED_BRIDGE_TOOL_NAMES,
)
from src.tool_execution import _ROUTED_BRIDGE_TOOLS
from src.tool_policy import known_tool_names
def test_tui_client_tool_contract_has_one_execution_owner_per_tool():
assert _ROUTED_BRIDGE_TOOLS == TUI_ROUTED_BRIDGE_TOOL_NAMES
assert _TUI_BRIDGE_TOOL_NAMES == TUI_CLIENT_TOOL_NAMES
assert TUI_CLIENT_TOOL_NAMES == (
TUI_ROUTED_BRIDGE_TOOL_NAMES | {"apply_patch", "host_shell"}
)
def test_every_canonical_tui_client_tool_has_a_model_schema():
legacy_transport_aliases = {"list_dir", "find_files"}
assert TUI_CLIENT_TOOL_NAMES - legacy_transport_aliases <= known_tool_names()
def test_runtime_context_parser_accepts_exactly_the_shared_client_tool_contract():
advertised = [
{"name": name} for name in sorted(TUI_CLIENT_TOOL_NAMES)
] + [{"name": "not_a_real_tool"}]
context = _parse_client_runtime_context(json.dumps({
"surface": "odysseus-tui",
"session_cwd": "/tmp/workspace",
"host_shell_bridge": {
"url": "http://127.0.0.1:17654/run", "token": "secret",
},
"client_tools": advertised,
}))
assert {item["name"] for item in context["client_tools"]} == TUI_CLIENT_TOOL_NAMES
def test_native_cook_context_is_normalized_to_unattended():
context = _parse_client_runtime_context({
"surface": "odysseus-native",
"terminal_agent": True,
"interaction_mode": "cook",
})
assert context["interaction_mode"] == "cook"
assert context["unattended_mode"] is True

Some files were not shown because too many files have changed in this diff Show More