test/contextual-clay-geometry-browser.test.cjs
Rama main · réplica de lectura del repositorio original.
var test = require('node:test');
var assert = require('node:assert/strict');
var cp = require('node:child_process');
var path = require('node:path');
var fs = require('node:fs');
var playwright = require('playwright');
async function waitForSettledReviewPaint(target) {
await target.evaluate(async function () {
// Flush theme styles before enumerating finite CSS transitions.
document.querySelectorAll('.ask-user-option').forEach(function (button) { getComputedStyle(button).backgroundColor; });
await new Promise(function (resolve) { requestAnimationFrame(resolve); });
var animations = document.getAnimations().filter(function (animation) { return animation.effect && animation.effect.getComputedTiming().iterations !== Infinity; });
await Promise.all(animations.map(function (animation) { return animation.finished.catch(function () {}); }));
await new Promise(function (resolve) { requestAnimationFrame(function () { requestAnimationFrame(resolve); }); });
});
}
async function reviewPaint(target) {
return target.evaluate(function () {
function luminance(color) {
var channels = color.match(/[\d.]+/g).slice(0, 3).map(function (value) { var channel = Number(value) / 255; return channel <= .04045 ? channel / 12.92 : Math.pow((channel + .055) / 1.055, 2.4); });
return channels[0] * .2126 + channels[1] * .7152 + channels[2] * .0722;
}
var probe = document.createElement('span'); probe.style.backgroundColor = getComputedStyle(document.documentElement).getPropertyValue('--bg-alt');
return { light: document.documentElement.classList.contains('light-theme'), expectedBackground: probe.style.backgroundColor, buttons: Array.from(document.querySelectorAll('.ask-user-container:not(.answered) .ask-user-option')).map(function (button) {
var background = getComputedStyle(button).backgroundColor;
var color = getComputedStyle(button.querySelector('.option-label')).color;
var a = luminance(background); var b = luminance(color);
return { text: button.textContent.trim(), background: background, color: color, contrast: (Math.max(a, b) + .05) / (Math.min(a, b) + .05) };
}) };
});
}
function assertSettledReview(paint, light) {
assert.equal(paint.light, light); assert.equal(paint.buttons.length, 2);
paint.buttons.forEach(function (button) { assert.equal(button.background, paint.expectedBackground, 'Settled option uses the active theme background'); assert.ok(button.contrast >= 4.5, button.text + ' contrast: ' + button.contrast); });
}
test('production popover geometry: drag, resize, continuity, cancellation, keyboard and owner persistence', async function (t) {
var child = cp.fork(path.join(__dirname, 'fixtures/contextual-clay-server.cjs'), [], { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] });
t.after(function () { child.kill('SIGTERM'); });
var output = ''; child.stdout.on('data', function (v) { output += v; }); child.stderr.on('data', function (v) { output += v; });
var info = await new Promise(function (resolve, reject) { var timer = setTimeout(function () { reject(new Error(output)); }, 15000); child.once('message', function (value) { clearTimeout(timer); resolve(value); }); });
var browser = await playwright.chromium.launch(); t.after(async function () { await browser.close(); });
var page = await browser.newPage({ viewport: { width: 1280, height: 900 }, reducedMotion: 'reduce' }); page.setDefaultTimeout(10000);
var errors = []; page.on('pageerror', function (error) { errors.push(error.message); });
var origin = 'http://127.0.0.1:' + info.port;
await page.goto(origin + '/fixture/login?to=alpha');
var trigger = page.locator('.icon-strip-brand'); await page.waitForFunction(function () { return !document.querySelector('.icon-strip-brand').disabled; });
await trigger.click();
var shell = page.locator('#contextual-clay'); var header = shell.locator('header');
var frame = page.frameLocator('#contextual-clay iframe');
await frame.locator('#messages').getByText('Isolated canonical integration conversation.', { exact: true }).waitFor();
await frame.locator('#input-wrapper:not([inert])').waitFor();
var input = frame.locator('#input'); var draft = 'Help me organise this project. Keep my current session open while we review the next steps.'; await input.fill(draft);
var context = page.frames().find(function (item) { return item.url().includes('contextual=1'); });
var session = await context.evaluate(async function () { window.geometryMarker = 'same renderer'; window.geometryStore = (await import('./modules/store.js')).store; return window.geometryStore.get('paneSessionId'); });
// Real wheel input disarms the native history-replay sticky-bottom window.
await frame.locator('#messages').hover(); await page.mouse.wheel(0, -500);
await context.evaluate(async function () { window.geometryScroll = await import('./modules/chat-render-runtime.js'); });
await context.waitForFunction(function () { return !window.geometryScroll.getStickyBottom(); });
var scrollPosition = await context.evaluate(function () { var scroll = document.getElementById('messages'); scroll.scrollTop = 45; document.getElementById('input').setSelectionRange(12, 20); return scroll.scrollTop; });
assert.ok(scrollPosition > 0, 'Real transcript is scrolled away from the latest message');
await page.evaluate(async function () { window.geometryState = (await import('/p/alpha/modules/store.js')).store; });
var initial = await shell.boundingBox(); var heading = await header.boundingBox();
var saves = 0; page.on('request', function (request) { if (request.method() === 'PUT' && request.url().endsWith('/api/user/contextual-clay-geometry')) saves++; });
await page.mouse.move(heading.x + 45, heading.y + 16); await page.mouse.down();
await page.mouse.move(heading.x + 210, heading.y + 85, { steps: 8 });
assert.equal(saves, 0, 'Do not write during pointer movement');
await page.mouse.up();
var moved = await shell.boundingBox(); assert.ok(moved.x > initial.x + 140); assert.ok(moved.y > initial.y + 50);
assert.equal(await context.evaluate(function () { return document.getElementById('messages').scrollTop; }), scrollPosition);
assert.deepEqual(await input.evaluate(function (el) { return [el.selectionStart, el.selectionEnd]; }), [12, 20]);
await page.waitForFunction(function () { var value = window.geometryState.get('contextualClayGeometry'); return !value.saving && !value.pending; });
assert.equal(saves, 1);
var corner = await shell.locator('[data-clay-resize="se"]').boundingBox();
await page.mouse.move(corner.x + 9, corner.y + 9); await page.mouse.down(); await page.mouse.move(corner.x + 125, corner.y + 89, { steps: 8 }); await page.mouse.up();
var resized = await shell.boundingBox(); assert.ok(resized.width > moved.width + 100); assert.ok(resized.height > moved.height + 60);
assert.ok(await context.evaluate(function () { var el = document.getElementById('messages'); return el.scrollTop < el.scrollHeight - el.clientHeight - 150; }), 'Resizing retains the reader position rather than forcing latest');
assert.equal(await input.inputValue(), draft); assert.equal(await context.evaluate(function () { return window.geometryMarker; }), 'same renderer');
assert.equal(await context.evaluate(function () { return window.geometryStore.get('paneSessionId'); }), session);
var review = page.request.get(origin + '/fixture/propose?viewer=alpha&target=beta');
var card = frame.locator('.ask-user-container:not(.answered)'); await card.waitFor(); var reviewId = await card.getAttribute('data-tool-id');
await header.focus(); await page.keyboard.press('ArrowRight'); await shell.locator('[data-clay-resize="se"]').focus(); await page.keyboard.press('ArrowLeft');
assert.equal(await card.getAttribute('data-tool-id'), reviewId); assert.equal(await input.inputValue(), draft);
await page.request.get(origin + '/fixture/stream'); await frame.locator('#generation-stop').waitFor({ state: 'visible' });
var beforeCancel = await shell.boundingBox(); heading = await header.boundingBox();
await page.mouse.move(heading.x + 45, heading.y + 16); await page.mouse.down(); await page.mouse.move(900, 500); await page.keyboard.press('Escape'); await page.mouse.up();
var cancelled = await shell.boundingBox(); assert.ok(Math.abs(cancelled.x - beforeCancel.x) < 2); assert.equal(await shell.isVisible(), true);
assert.equal(await shell.evaluate(function (el) { return el.classList.contains('contextual-clay-manipulating'); }), false);
heading = await header.boundingBox(); await page.mouse.move(heading.x + 45, heading.y + 16); await page.mouse.down(); await page.mouse.move(heading.x + 70, heading.y + 30);
await header.dispatchEvent('pointercancel', { pointerId: 1 }); await page.mouse.up();
assert.equal(await shell.evaluate(function (el) { return el.classList.contains('contextual-clay-manipulating'); }), false);
assert.ok(Math.abs((await shell.boundingBox()).x - beforeCancel.x) < 2);
var handle = shell.locator('[data-clay-resize="se"]'); corner = await handle.boundingBox();
await page.mouse.move(corner.x + 9, corner.y + 9); await page.mouse.down(); await page.mouse.move(0, 0); await page.mouse.up();
var minimum = await shell.boundingBox(); assert.equal(Math.round(minimum.width), 340); assert.equal(Math.round(minimum.height), 360);
corner = await handle.boundingBox(); await page.mouse.move(corner.x + 9, corner.y + 9); await page.mouse.down(); await page.mouse.move(3000, 2500); await page.mouse.up();
var maximum = await shell.boundingBox(); assert.ok(maximum.x + maximum.width <= 1273); assert.ok(maximum.y + maximum.height <= 893);
// Choose a readable final rectangle and wait for the serialized final save.
await page.evaluate(async function () { window.geometryState = (await import('/p/alpha/modules/store.js')).store; });
await page.waitForFunction(function () { var value = window.geometryState.get('contextualClayGeometry'); return !value.saving && !value.pending; });
var saved = (await page.request.get(origin + '/api/user/contextual-clay-geometry')).json(); saved = (await saved).geometry;
var foreign = await browser.newContext();
var foreignResponse = await foreign.request.get(origin + '/api/user/contextual-clay-geometry', { headers: { Cookie: 'relay_auth_user=fixture-foreign-token' } });
assert.equal(foreignResponse.status(), 200); assert.equal((await foreignResponse.json()).geometry, null);
assert.equal((await foreign.request.put(origin + '/api/user/contextual-clay-geometry', { headers: { Cookie: 'relay_auth_user=fixture-foreign-token', 'X-Clay-Settings': '1' }, data: { left: 20, top: 20, width: 400, height: 500 } })).status(), 200);
assert.deepEqual((await (await page.request.get(origin + '/api/user/contextual-clay-geometry')).json()).geometry, saved);
assert.equal((await foreign.request.put(origin + '/api/user/contextual-clay-geometry', { headers: { Cookie: 'relay_auth_user=fixture-foreign-token', 'X-Clay-Settings': '1' }, data: { left: -1, top: 0, width: 400, height: 500 } })).status(), 400);
assert.equal((await foreign.request.get(origin + '/api/user/contextual-clay-geometry')).status(), 401);
await foreign.close();
await page.getByRole('button', { name: 'Close Clay conversation', exact: true }).click(); await trigger.click();
assert.equal(await input.inputValue(), draft); assert.equal(await card.getAttribute('data-tool-id'), reviewId);
var reopen = await shell.boundingBox(); assert.ok(Math.abs(reopen.width - maximum.width) < 2);
await page.setViewportSize({ width: 390, height: 844 }); var mobile = await shell.boundingBox(); assert.ok(mobile.x >= 0 && mobile.x + mobile.width <= 390); assert.equal(await handle.isVisible(), false);
await frame.locator('#contextual-attach-btn').click();
assert.equal(await frame.locator('#attach-image-btn').isVisible(), true);
await frame.locator('#attach-image-btn').press('Escape');
await page.setViewportSize({ width: 800, height: 600 });
var clamped = await shell.boundingBox(); assert.ok(clamped.width <= 784 && clamped.height <= 584);
var savesBeforeClick = saves; heading = await header.boundingBox(); await page.mouse.click(heading.x + 45, heading.y + 16); assert.equal(saves, savesBeforeClick);
await page.setViewportSize({ width: 1280, height: 900 });
await page.waitForFunction(function (width) { return Math.abs(document.getElementById('contextual-clay').getBoundingClientRect().width - width) < 2; }, maximum.width);
var restored = await shell.boundingBox(); assert.ok(Math.abs(restored.width - maximum.width) < 2);
assert.deepEqual((await (await page.request.get(origin + '/api/user/contextual-clay-geometry')).json()).geometry, saved);
var beforeHeaderClick = saves; heading = await header.boundingBox(); await page.mouse.click(heading.x + 45, heading.y + 16); assert.equal(saves, beforeHeaderClick);
assert.equal(await frame.locator('#generation-stop').isVisible(), true);
await page.screenshot({ path: '/tmp/contextual-geometry-desktop.png' });
await page.evaluate(async function () { (await import('/p/alpha/modules/theme.js')).applyTheme('clay-dark'); });
await context.evaluate(async function () { (await import('./modules/theme.js')).applyTheme('clay-dark'); });
await waitForSettledReviewPaint(page); await waitForSettledReviewPaint(context);
var darkPaint = await reviewPaint(context); assertSettledReview(darkPaint, false);
await page.screenshot({ path: '/tmp/contextual-geometry-desktop-dark.png' });
await page.setViewportSize({ width: 390, height: 844 }); await page.screenshot({ path: '/tmp/contextual-geometry-mobile.png' });
await page.evaluate(async function () { (await import('/p/alpha/modules/theme.js')).applyTheme('clay-light'); });
await context.evaluate(async function () { (await import('./modules/theme.js')).applyTheme('clay-light'); });
var immediateLightPaint = await reviewPaint(context);
await waitForSettledReviewPaint(page); await waitForSettledReviewPaint(context);
var settledLightPaint = await reviewPaint(context); assertSettledReview(settledLightPaint, true);
t.diagnostic('Dark-to-light review paint: ' + JSON.stringify({ immediate: immediateLightPaint, settled: settledLightPaint }));
await page.screenshot({ path: '/tmp/contextual-review-theme-settled-light.png' });
var capturedLightPaint = await reviewPaint(context); assertSettledReview(capturedLightPaint, true);
fs.writeFileSync('/tmp/contextual-review-theme-paint.json', JSON.stringify({ capturedAt: new Date().toISOString(), cause: 'The option background has a 150 ms CSS transition; label colour changes immediately.', dark: darkPaint, immediateLight: immediateLightPaint, settledLight: settledLightPaint, afterScreenshot: capturedLightPaint, screenshot: '/tmp/contextual-review-theme-settled-light.png' }, null, 2));
await page.screenshot({ path: '/tmp/contextual-geometry-mobile-light.png' });
await page.setViewportSize({ width: 1280, height: 900 });
await card.getByText('Keep unchanged', { exact: true }).click(); await card.locator('.ask-user-submit').click(); assert.equal((await (await review).json()).status, 'cancelled');
await page.reload(); await page.waitForFunction(function () { return !document.querySelector('.icon-strip-brand').disabled; }); await trigger.click();
await page.evaluate(async function () { window.geometryState = (await import('/p/alpha/modules/store.js')).store; });
await page.waitForFunction(function () { return !!window.geometryState.get('contextualClayGeometry').desired; });
var reloaded = await shell.boundingBox(); assert.ok(Math.abs(reloaded.width - saved.width) < 2); assert.ok(Math.abs(reloaded.height - saved.height) < 2);
await shell.getByLabel('What this Mate can see', { exact: true }).click(); assert.equal(await shell.locator('.contextual-clay-context').getAttribute('open'), ''); await page.keyboard.press('Escape'); assert.equal(await shell.isVisible(), true);
await frame.locator('#messages').getByText('Isolated canonical integration conversation.', { exact: true }).waitFor();
await frame.locator('#input-wrapper:not([inert])').waitFor();
await page.getByRole('button', { name: 'Open Clay', exact: true }).click(); await page.waitForURL('**/p/' + info.sourceSlug + '/');
assert.deepEqual(errors, []);
t.diagnostic('Observed real pointer drag/resize, 340×360 minimum, viewport maximum, Escape cancellation, keyboard controls, persisted reload, mobile restoration, and unchanged draft/session/review/Stop.');
});