mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-09 08:22:19 +02:00
Squash Odysseus development history
This commit is contained in:
@@ -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 }], []), []);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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
@@ -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:",
|
||||
"---"
|
||||
]
|
||||
}
|
||||
]
|
||||
Vendored
+12
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+6
@@ -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
@@ -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();
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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?")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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) == ""
|
||||
@@ -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"}'),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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"
|
||||
@@ -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]
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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']
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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']
|
||||
@@ -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", "")
|
||||
|
||||
|
||||
@@ -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"}]
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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'
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
Reference in New Issue
Block a user