var test = require('node:test'), assert = require('node:assert/strict'), cp = require('node:child_process'), path = require('node:path'), playwright = require('playwright'); test('Clay and Mate windows retain conversations through navigation and refresh the exact current context', async function (t) { var child = cp.fork(path.join(__dirname, 'fixtures/contextual-clay-server.cjs'), [], { env: Object.assign({}, process.env, { CLAY_CONTEXT_ACCESS_FIXTURE: '1' }), 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(function () { return browser.close(); }); var page = await browser.newPage({ viewport: { width: 1280, height: 800 }, reducedMotion: 'reduce' }); page.setDefaultTimeout(10000); var origin = 'http://127.0.0.1:' + info.port, errors = []; page.on('pageerror', function (error) { errors.push(error.message); }); await page.goto(origin + '/fixture/login?to=alpha', { waitUntil: 'domcontentloaded' }); await page.waitForFunction(function () { return !document.querySelector('.icon-strip-brand').disabled; }); await page.evaluate(async function () { window.lifecycleStore = (await import('./modules/store.js')).store; }); var destination = await (await page.request.get(origin + '/fixture/state')).json(); var targets = [{ id: info.sourceSlug.slice(5), slug: info.sourceSlug, name: 'Clay' }, info.ordinaryMates[0]]; function surface() { return page.locator('#contextual-clay'); } function frame() { return page.frameLocator('#contextual-clay iframe:visible'); } async function ready() { await page.waitForFunction(function () { var pane = document.querySelector('#contextual-clay iframe:not([hidden])'); return pane && pane.conversation && pane.conversation.identity().ready; }); } async function current() { return page.evaluate(function () { var prepared = window.lifecycleStore.get('contextualPrepared'); return { requestId: prepared.requestId, ref: prepared.contextRef, status: prepared.status, snapshot: JSON.parse(document.getElementById('contextual-clay').dataset.screenReference) }; }); } async function select(slug, session) { await page.evaluate(async function (target) { (await import('/p/alpha/modules/project-session-navigation.js')).openProjectSession(target.slug, target.session); }, { slug: slug, session: session }); await page.waitForFunction(function (target) { return window.lifecycleStore.get('currentSlug') === target.slug && window.lifecycleStore.get('activeSessionId') === target.session && !window.lifecycleStore.get('replayingHistory') && window.lifecycleStore.get('sessionActivatedProjectSlug') === target.slug; }, { slug: slug, session: session }); } async function restricted(name, args) { return (await page.request.get(origin + '/fixture/context-tool?name=' + name + '&args=' + encodeURIComponent(JSON.stringify(args || {})))).json(); } for (var target of targets) { await select('alpha', destination.alphaSessionId); var trigger = page.locator(target.name === 'Clay' ? '.icon-strip-brand' : '.icon-strip-project-mate[data-mate-id="' + target.id + '"]'); await trigger.click(); await ready(); var paneWindow = page.frames().find(function (item) { return item.url().includes(target.slug); }); await paneWindow.evaluate(function () { window.lifecycleRenderer = 'retained'; }); if (target.name !== 'Clay') { await page.locator('[data-context-status]').filter({ hasText: 'Next message' }).waitFor(); await frame().locator('#input').fill('Share this exact Alpha conversation.'); await frame().locator('#send-btn').click(); await page.locator('[data-context-status]').filter({ hasText: 'Shared' }).waitFor(); var shared = await restricted('read_current_context'); assert.equal(shared.status, 'shared_snapshot'); } var draft = target.name + ' retained draft'; await frame().locator('#input').fill(draft); var first = await current(); await page.mouse.click(1100, 650); assert.equal(await surface().isVisible(), true, 'Outside click keeps ' + target.name + ' open'); await page.locator('#input').focus(); await page.keyboard.press('Escape'); assert.equal(await surface().isVisible(), true, 'Escape in the background belongs to the background'); await page.getByRole('link', { name: 'BI', exact: true }).click(); await page.waitForURL('**/p/beta/'); await page.locator('#messages').getByText('BETA_RENDERED_DESTINATION', { exact: true }).waitFor(); await page.locator('[data-context-project]').filter({ hasText: 'Beta' }).waitFor(); assert.equal(await surface().isVisible(), true); assert.equal(await frame().locator('#input').inputValue(), draft); assert.equal(await paneWindow.evaluate(function () { return window.lifecycleRenderer; }), 'retained'); var beta = await current(); assert.notEqual(beta.requestId, first.requestId); assert.equal(beta.snapshot.projectSlug, 'beta'); assert.equal(beta.snapshot.sessionId, destination.betaSessionId); if (target.name !== 'Clay') { assert.equal((await restricted('read_selected_context', { contextRef: shared.contextRef })).status, 'failed', 'Old query loses access after navigation'); } // Create and select through the same production session service used by the sidebar. await page.evaluate(async function () { (await import('/p/alpha/modules/sidebar-sessions.js')).startNewSession('claude'); }); await page.waitForFunction(function (old) { return window.lifecycleStore.get('activeSessionId') !== old && !window.lifecycleStore.get('replayingHistory'); }, destination.betaSessionId); var next = await current(); assert.notEqual(next.requestId, beta.requestId); assert.notEqual(next.snapshot.sessionId, beta.snapshot.sessionId); assert.equal(await surface().isVisible(), true); assert.equal(await frame().locator('#input').inputValue(), draft); // Hold a real preparation reply, change to Home, then release the stale reply. await page.evaluate(async function () { var socket = (await import('/p/alpha/modules/ws-ref.js')).getWs(), receive = socket.onmessage; window.heldPreparation = null; socket.onmessage = function (event) { if (JSON.parse(event.data).type === 'mate_context_prepared' && !window.heldPreparation) { window.heldPreparation = event; return; } receive(event); }; window.releasePreparation = function () { socket.onmessage = receive; receive(window.heldPreparation); }; }); await select('beta', destination.betaSessionId); await page.waitForFunction(function () { return !!window.heldPreparation; }); // The standalone project list is gone; the Home context is the zero-project welcome surface, entered here through its store flag while the list is still loading. await page.evaluate(async function () { (await import('/p/alpha/modules/store.js')).store.set({ projectListLoaded: false, projectsHubVisible: true, pendingHomeProjectSlug: null }); }); var home = await current(); assert.equal(home.snapshot.screen, 'Home'); assert.equal(home.snapshot.projectTitle, 'Projects'); assert.equal(home.snapshot.sessionId, null); assert.equal(home.snapshot.projectSlug, null); assert.equal(home.snapshot.conversationTitle, null); await page.evaluate(function () { window.releasePreparation(); }); assert.equal((await current()).requestId, home.requestId, 'Stale preparation cannot replace Home context'); if (target.name === 'Clay') { var sentSnapshot = await paneWindow.evaluate(async function () { return (await import('./modules/contextual-clay-session.js')).contextualScreenReference(); }); assert.equal(sentSnapshot.screen, 'Home'); assert.equal(sentSnapshot.projectTitle, 'Projects'); } assert.equal(!!(await current()).ref, false); assert.notEqual(await page.evaluate(function () { return JSON.parse(window.heldPreparation.data).requestId; }), home.requestId); assert.equal(await surface().isVisible(), true); assert.equal(await frame().locator('#input').inputValue(), draft); await page.evaluate(async function () { (await import('/p/alpha/modules/store.js')).store.set({ projectsHubVisible: false, projectListLoaded: true }); }); await select('alpha', destination.alphaSessionId); await page.locator('[data-context-project]').filter({ hasText: 'Alpha' }).waitFor(); for (var width of [1280, 390]) { await page.setViewportSize({ width: width, height: 844 }); var label = page.getByRole('button', { name: 'New chat', exact: true }); assert.equal((await label.innerText()).trim(), 'New chat'); assert.equal(await label.locator('span').isVisible(), true); var labelBox = await label.boundingBox(), shellBox = await surface().boundingBox(); assert.ok(labelBox.x >= shellBox.x && labelBox.x + labelBox.width <= shellBox.x + shellBox.width); assert.equal(await surface().evaluate(function (el) { return el.scrollWidth <= el.clientWidth; }), true); await page.screenshot({ path: '/tmp/contextual-window-' + target.name + '-' + width + '.png', animations: 'disabled' }); } await page.setViewportSize({ width: 1280, height: 800 }); await frame().locator('#input').press('Escape'); assert.equal(await surface().isVisible(), false); await trigger.click(); await ready(); assert.equal(await frame().locator('#input').inputValue(), draft); await page.locator('[data-clay-close]').click(); assert.equal(await surface().isVisible(), false); await trigger.click(); await ready(); await trigger.click(); await page.waitForURL('**/p/' + target.slug + '/'); await page.waitForFunction(function () { return document.body.classList.contains('contextual-clay-native') && !document.getElementById('input-wrapper').inert; }); assert.equal(await surface().isVisible(), false); assert.equal(await page.locator('#input').inputValue(), draft); await page.locator('#contextual-workspace-back').click(); await page.waitForURL('**/p/alpha/'); await page.waitForFunction(function () { return !window.lifecycleStore.get('contextualWorkspace'); }); await trigger.click(); await ready(); await page.evaluate(function (id) { window.lifecycleMates = window.lifecycleStore.get('cachedMatesList'); window.lifecycleStore.set({ cachedMatesList: window.lifecycleMates.filter(function (mate) { return mate.id !== id; }) }); }, target.id); assert.equal(await surface().isVisible(), false); assert.equal(await page.locator('#contextual-clay iframe[data-mate-id="' + target.id + '"]').count(), 0, 'Deleted identity tears down the renderer'); await page.evaluate(function () { window.lifecycleStore.set({ cachedMatesList: window.lifecycleMates }); }); } // The canonical brand returns from its own native route; ordinary Mate icons can lend a direct native composer. for (var nativeTarget of targets.filter(function (item) { return item.name !== 'Clay'; })) { var direct = await browser.newPage({ viewport: { width: 1280, height: 800 }, reducedMotion: 'reduce' }); direct.setDefaultTimeout(10000); await direct.goto(origin + '/fixture/login?to=alpha', { waitUntil: 'domcontentloaded' }); await direct.goto(origin + '/p/' + nativeTarget.slug + '/', { waitUntil: 'domcontentloaded' }); await direct.evaluate(async function () { window.directLifecycleStore = (await import('./modules/store.js')).store; }); await direct.waitForFunction(function () { return window.directLifecycleStore.get('activeSessionId') && !window.directLifecycleStore.get('replayingHistory'); }); var nativeDraft = 'Direct native draft for ' + nativeTarget.name; await direct.locator('#input').fill(nativeDraft); var nativeTrigger = direct.locator(nativeTarget.name === 'Clay' ? '.icon-strip-brand' : '.icon-strip-project-mate[data-mate-id="' + nativeTarget.id + '"]'); await nativeTrigger.click(); var directPane = direct.frameLocator('#contextual-clay iframe:visible'); await directPane.locator('#input-wrapper:not([inert])').waitFor(); await direct.waitForFunction(function (draft) { var pane = document.querySelector('#contextual-clay iframe:not([hidden])'); return pane && pane.contentDocument.getElementById('input').value === draft; }, nativeDraft); await direct.getByRole('link', { name: 'BI', exact: true }).click(); await direct.waitForURL('**/p/beta/'); await direct.locator('#messages').getByText('BETA_RENDERED_DESTINATION', { exact: true }).waitFor(); assert.equal(await direct.locator('#contextual-clay').isVisible(), true); assert.equal(await directPane.locator('#input').inputValue(), nativeDraft, 'The pane retains its draft after leaving its direct native origin'); assert.equal(await directPane.locator('#input').getAttribute('readonly'), null); assert.notEqual(await direct.locator('#input').inputValue(), nativeDraft, 'The destination composer never receives another Mate draft'); assert.equal(await direct.evaluate(function () { return window.directLifecycleStore.get('contextualNativeOrigin'); }), null); await direct.locator('[data-clay-expand]').click(); await direct.waitForURL('**/p/' + nativeTarget.slug + '/'); await direct.waitForFunction(function () { return document.body.classList.contains('contextual-clay-native') && !document.getElementById('input-wrapper').inert; }); assert.equal(await direct.locator('#input').inputValue(), nativeDraft); var other = targets.find(function (item) { return item.id !== nativeTarget.id; }); await direct.locator(other.name === 'Clay' ? '.icon-strip-brand' : '.icon-strip-project-mate[data-mate-id="' + other.id + '"]').click(); await directPane.locator('#input-wrapper:not([inert])').waitFor(); await directPane.locator('#input').fill('Other floating writer'); await direct.getByRole('link', { name: 'BI', exact: true }).click(); await direct.waitForURL('**/p/beta/'); await direct.locator('#messages').getByText('BETA_RENDERED_DESTINATION', { exact: true }).waitFor(); assert.equal(await direct.locator('#contextual-clay').isVisible(), true, 'Leaving a full-screen Mate preserves another open floating conversation'); assert.equal(await directPane.locator('#input').inputValue(), 'Other floating writer'); await direct.evaluate(function () { window.directLifecycleStore.set({ myUserId: 'foreign' }); }); assert.equal(await direct.locator('#contextual-clay').isVisible(), false); assert.equal(await direct.locator('#contextual-clay iframe').count(), 0, 'Owner change tears down every private pane'); await direct.close(); } assert.deepEqual(errors, []); });