test/contextual-composer-browser.test.cjs
Rama main · réplica de lectura del repositorio original.
var test = require('node:test'), assert = require('node:assert/strict'), cp = require('node:child_process'), path = require('node:path'), playwright = require('playwright');
test('floating composers contain attachment/model controls and use real chooser and model acknowledgement paths', 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 (data) { output += data; }); child.stderr.on('data', function (data) { output += data; });
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: 800 }, reducedMotion: 'reduce' }); page.setDefaultTimeout(10000);
await page.addInitScript(function () {
// Exercise the production speech lifecycle without opening a microphone or
// contacting a recognition provider.
window.AudioContext = undefined; window.webkitAudioContext = undefined;
window.SpeechRecognition = function () { window.composerRecognition = this; };
window.SpeechRecognition.prototype.start = function () { if (this.onstart) this.onstart(); };
window.SpeechRecognition.prototype.abort = function () {};
});
var errors = []; page.on('pageerror', function (error) { errors.push(error.message); });
await page.goto('http://127.0.0.1:' + info.port + '/fixture/login?to=alpha');
assert.equal(await page.locator('#contextual-attach-btn').count(), 0, 'Native project composer is unchanged');
var mates = [{ id: info.sourceSlug.slice(5), slug: info.sourceSlug, canonical: true }, info.ordinaryMates[0]];
for (var mate of mates) {
await page.setViewportSize({ width: 1280, height: 800 });
var icon = mate.canonical ? page.locator('.icon-strip-brand') : page.locator('.icon-strip-project-mate[data-mate-id="' + mate.id + '"]');
await icon.click();
var pane = page.frameLocator('#contextual-clay iframe[data-mate-id="' + mate.id + '"]');
await page.waitForFunction(function (id) { var f = document.querySelector('#contextual-clay iframe[data-mate-id="' + id + '"]'); return f && f.conversation && f.conversation.identity().ready; }, mate.id);
var windowFrame = page.frames().find(function (frame) { return frame.url().includes(mate.slug); });
var input = pane.locator('#input');
await input.fill('A multiline draft\nwith its caret and attachments retained.');
for (var width of [1280, 390]) {
await page.setViewportSize({ width: width, height: 844 });
await pane.locator('#contextual-attach-btn').focus(); await page.keyboard.press('Enter');
await pane.getByRole('dialog', { name: 'Attachments and context' }).waitFor();
assert.equal(await pane.locator('#attach-file-btn').evaluate(function (node) { return node === document.activeElement; }), true);
await page.keyboard.press('ArrowDown');
assert.equal(await pane.locator('#attach-image-btn').evaluate(function (node) { return node === document.activeElement; }), true);
var bounds = await pane.locator('#contextual-attach-popover').evaluate(function (node) { var r = node.getBoundingClientRect(); return r.left >= 0 && r.top >= 0 && r.right <= innerWidth && r.bottom <= innerHeight; });
assert.equal(bounds, true, 'Attachment popover stays within iframe');
await page.keyboard.press('Escape');
assert.equal(await pane.locator('#contextual-attach-btn').getAttribute('aria-expanded'), 'false');
assert.equal(await page.locator('#contextual-clay').isVisible(), true, 'Menu Escape leaves chat open');
await pane.locator('#contextual-attach-btn').click();
await pane.locator('#context-sources-add').focus(); await page.keyboard.press('Tab');
await pane.locator('#contextual-attach-popover').waitFor({ state: 'hidden' });
assert.equal(await page.locator('#contextual-clay').isVisible(), true, 'Tab exits the menu without dismissing chat');
var geometry = await windowFrame.evaluate(function () {
var area = document.getElementById('input-row').getBoundingClientRect();
return ['contextual-attach-btn', 'config-chip', 'stt-btn', 'send-btn'].map(function (id) { var r = document.getElementById(id).getBoundingClientRect(); return { id: id, inside: r.left >= area.left && r.right <= area.right + 1 && r.top >= area.top && r.bottom <= area.bottom + 1 }; });
});
geometry.forEach(function (entry) { assert.equal(entry.inside, true, entry.id + ' is inside composer at ' + width); });
await pane.locator('#config-chip').click(); await pane.locator('#config-popover:not(.hidden)').waitFor();
assert.match(await pane.locator('#config-chip-label').textContent(), /Fixture/);
assert.equal(await pane.locator('#config-popover').evaluate(function (node) { var r = node.getBoundingClientRect(); return r.left >= 0 && r.top >= 0 && r.right <= innerWidth && r.bottom <= innerHeight; }), true);
await page.keyboard.press('Escape'); assert.equal(await page.locator('#contextual-clay').isVisible(), true);
await page.screenshot({ path: '/tmp/contextual-composer-' + (mate.canonical ? 'clay' : 'ordinary') + '-' + width + '.png', animations: 'disabled' });
}
for (var kind of ['file', 'image']) {
t.diagnostic('Chooser: ' + mate.id + ' / ' + kind);
await pane.locator('#contextual-attach-btn').click();
var chooser = page.waitForEvent('filechooser'); await pane.locator('#attach-' + kind + '-btn').click();
await (await chooser).setFiles(kind === 'file' ? { name: 'composer-note.txt', mimeType: 'text/plain', buffer: Buffer.from('Composer attachment') } : { name: 'composer-pixel.png', mimeType: 'image/png', buffer: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aN1sAAAAASUVORK5CYII=', 'base64') });
assert.equal(await pane.locator('#contextual-attach-btn').getAttribute('aria-expanded'), 'false');
}
await pane.locator('#image-preview-bar img').waitFor();
await pane.locator('#image-preview-bar').getByText('composer-note.txt', { exact: false }).waitFor();
await windowFrame.evaluate(async function () { (await import('./modules/ws-ref.js')).getWs().addEventListener('message', function (event) { var msg = JSON.parse(event.data); if (msg.type === 'model_selection_result') window.composerModelAck = msg; }); });
await pane.locator('#config-chip').click();
await pane.locator('#config-model-list button[data-model="fixture-model-alternative"]').click();
await windowFrame.waitForFunction(function () { return window.composerModelAck && window.composerModelAck.ok && window.composerModelAck.model === 'fixture-model-alternative'; });
assert.match(await pane.locator('#config-chip-label').textContent(), /Fixture alternative/);
assert.equal(await input.inputValue(), 'A multiline draft\nwith its caret and attachments retained.');
await pane.locator('#stt-btn-picker').click(); await pane.locator('#speech-picker').waitFor();
await page.keyboard.press('Escape'); assert.equal(await page.locator('#contextual-clay').isVisible(), true);
await pane.locator('#stt-btn').click(); await pane.locator('#input-row.stt-recording').waitFor();
await windowFrame.evaluate(function () { window.composerRecognition.onresult({ results: [[{ transcript: 'Voice continuation' }]] }); });
assert.match(await input.inputValue(), /retained\. Voice continuation$/);
assert.equal(await pane.locator('.stt-cancel').evaluate(function (node) { var r = node.getBoundingClientRect(), row = document.getElementById('input-row').getBoundingClientRect(); return r.left >= row.left && r.right <= row.right && r.bottom <= row.bottom; }), true);
await page.screenshot({ path: '/tmp/contextual-composer-voice-' + (mate.canonical ? 'clay' : 'ordinary') + '.png', animations: 'disabled' });
await pane.locator('#stt-btn').click();
assert.equal(await pane.locator('#input-row.stt-recording').count(), 0);
assert.match(await input.inputValue(), /Voice continuation$/);
await input.fill('A multiline draft\nwith its caret and attachments retained.');
await input.press('Escape'); await page.setViewportSize({ width: 1280, height: 800 }); await icon.click();
assert.equal(await input.inputValue(), 'A multiline draft\nwith its caret and attachments retained.');
assert.match(await pane.locator('#config-chip-label').textContent(), /Fixture alternative/);
await page.locator('[data-clay-close]').click();
}
assert.deepEqual(errors, []);
t.diagnostic('Canonical and ordinary: inside-composer geometry at desktop/narrow, keyboard attachment menu, bounded model menu, native File/Image chooser, actual alternative-model ACK, draft/attachment/voice and dismissal continuity; native project composer unchanged.');
});