Files
nest/tests/browser_sessions.cjs
T
kstyagi 645bf6321a feat: add session rename and delete functionality with validation
- Introduced `SessionRename` model for renaming sessions with validation on name length and content.
- Implemented `rename_session` endpoint in `session_routes.py` to handle session renaming.
- Added `delete_session` endpoint to remove sessions, ensuring proper cleanup of associated data.
- Enhanced `SessionStore` with `rename` and `delete` methods, including validation and transaction management.
- Updated `SessionManager` to manage session renaming and deletion, ensuring live terminal state is preserved.
- Added tests for renaming and deleting sessions, ensuring proper functionality and validation.
- Introduced a new `tool_catalog.py` for managing guest tool selections and installation recipes.
2026-09-25 22:59:32 +05:30

182 lines
10 KiB
JavaScript

// Optional integration test: requires Playwright + Chromium and the project's test extra.
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const { mkdtemp, rm } = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { chromium } = require('playwright');
(async () => {
const temporary = await mkdtemp(path.join(os.tmpdir(), 'nest-browser-'));
const port = process.env.NEST_BROWSER_TEST_PORT || '18765';
const origin = `http://127.0.0.1:${port}`;
const server = spawn(process.env.NEST_TEST_PYTHON || 'python3',
['-B', '-m', 'tests.browser_session_server', '--base', temporary, '--port', port],
{ cwd: path.resolve(__dirname, '..'), stdio: ['ignore', 'pipe', 'pipe'] });
let logs = '';
server.stdout.on('data', (data) => { logs = (logs + data).slice(-16000); });
server.stderr.on('data', (data) => { logs = (logs + data).slice(-16000); });
let browser;
let page;
try {
let ready = false;
for (let attempt = 0; attempt < 100; attempt++) {
if (server.exitCode !== null) throw new Error(`Fixture server exited: ${logs}`);
try {
const response = await fetch(`${origin}/health`);
if (response.ok) { ready = true; break; }
} catch {}
await new Promise((resolve) => setTimeout(resolve, 100));
}
assert.ok(ready, `Fixture server did not start: ${logs}`);
browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 1100 } });
page = await context.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const connected = () => page.waitForFunction(() => document.querySelector('#session-status').textContent === 'Connected');
const output = (value) => page.waitForFunction((text) => document.querySelector('#session-terminal').textContent.includes(text), value);
await page.goto(`${origin}/dashboard#vm/vm-test`);
await page.locator('#token-input').fill('browser-test-token');
await page.locator('#auth-btn').click();
await page.locator('#detail-actions [data-action="launch-session"]').click();
await connected();
const firstUrl = page.url();
assert.match(firstUrl, /#vm\/vm-test\/sessions\/session-/);
await page.keyboard.type('echo alpha');
await page.keyboard.press('Enter');
await output('result:echo alpha');
await page.reload();
await connected();
await output('result:echo alpha');
assert.equal(await page.locator('.session-item').count(), 1);
// Rename is cancellable, validated, persisted, and preserves the live widget.
const firstId = new URL(firstUrl).hash.split('/').at(-1);
const firstRow = () => page.locator(`.session-item[data-session-id="${firstId}"]`);
await page.locator('#session-rename').click();
await page.locator('#session-name-input').fill('Discarded name');
await page.locator('#session-rename-cancel').click();
assert.equal(await firstRow().locator('strong').textContent(), 'Session 1');
await page.evaluate(() => { window.originalTerminal = document.querySelector('#session-terminal .xterm'); });
await page.locator('#session-rename').click();
await page.locator('#session-name-input').fill(' ');
await page.locator('#session-rename-save').click();
assert.ok(await page.locator('#session-rename-dialog').isVisible());
assert.match(await page.locator('#session-rename-error').textContent(), /Enter a session name/);
await page.locator('#session-name-input').fill(' Deployment ');
await page.locator('#session-rename-save').click();
await page.locator('#session-rename-dialog').waitFor({ state: 'hidden' });
await page.waitForFunction(() => document.querySelector('#session-title').textContent.startsWith('Deployment'));
assert.equal(page.url(), firstUrl);
assert.ok(await page.evaluate(() => window.originalTerminal === document.querySelector('#session-terminal .xterm')));
await page.reload();
await connected();
await output('result:echo alpha');
assert.equal(await firstRow().locator('strong').textContent(), 'Deployment');
await page.locator('#detail-actions [data-action="launch-session"]').click();
await page.waitForURL((url) => url.href !== firstUrl);
await connected();
const secondUrl = page.url();
assert.equal(await page.locator('.session-item').count(), 2);
assert.ok(!(await page.locator('#session-terminal').textContent()).includes('result:echo alpha'));
await page.keyboard.type('echo beta');
await page.keyboard.press('Enter');
await output('result:echo beta');
// History actions also work without selecting the affected session.
await firstRow().locator('[data-session-action="rename"]').click();
await page.locator('#session-name-input').fill('Operations <alpha>');
await page.locator('#session-rename-save').click();
await page.locator('#session-rename-dialog').waitFor({ state: 'hidden' });
assert.equal(page.url(), secondUrl);
assert.equal(await firstRow().locator('strong').textContent(), 'Operations <alpha>');
assert.equal(await firstRow().locator('alpha').count(), 0);
await output('result:echo beta');
await firstRow().locator('.session-link').click();
await connected();
await output('result:echo alpha');
assert.ok(!(await page.locator('#session-terminal').textContent()).includes('result:echo beta'));
await page.goto(`${origin}/dashboard#vm/vm-other/sessions`);
await page.locator('#vm-sessions-panel').waitFor({ state: 'visible' });
await page.waitForFunction(() => document.querySelector('#vm-session-count').textContent === '0');
assert.equal(await page.locator('.session-item').count(), 0);
assert.ok(await page.locator('#detail-actions [data-action="launch-session"]').isDisabled());
assert.ok(!(await page.locator('#session-terminal').textContent()).includes('alpha'));
await page.goto(firstUrl);
await connected();
await output('result:echo alpha');
await page.locator('#session-end').click();
await page.locator('#confirm-ok').click();
await page.waitForFunction(() => document.querySelector('#session-status').textContent.startsWith('closed'));
await page.reload();
await output('result:echo alpha');
assert.ok(await page.locator('#session-end').isDisabled());
await page.locator('#session-rename').click();
await page.locator('#session-name-input').fill('Archived run');
await page.locator('#session-rename-save').click();
await page.locator('#session-rename-dialog').waitFor({ state: 'hidden' });
await page.reload();
await output('result:echo alpha');
assert.equal(await firstRow().locator('strong').textContent(), 'Archived run');
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(300);
const fits = await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth);
assert.ok(fits, 'mobile layout overflows the viewport');
if (process.env.NEST_BROWSER_SCREENSHOT) await page.screenshot({ path: process.env.NEST_BROWSER_SCREENSHOT, fullPage: true });
// Cancel preserves history; confirmation removes an ended session and clears selection.
await page.locator('#session-delete').click();
await page.locator('#confirm-dialog button[value="cancel"]').click();
assert.equal(await page.locator('.session-item').count(), 2);
await firstRow().locator('[data-session-action="delete"]').click();
await page.locator('#confirm-ok').click();
await page.waitForURL((url) => url.hash === '#vm/vm-test/sessions');
await page.waitForFunction(() => document.querySelector('#vm-session-count').textContent === '1');
assert.ok(await page.locator('#session-terminal').isHidden());
await page.reload();
await page.waitForFunction(() => document.querySelector('#vm-session-count').textContent === '1');
assert.equal(await firstRow().count(), 0);
// Deleting an active session clears other attached browser views as well.
await page.goto(secondUrl);
await connected();
await output('result:echo beta');
const observer = await page.context().newPage();
observer.on('pageerror', (error) => errors.push(error.message));
await observer.goto(secondUrl);
await observer.locator('#token-input').fill('browser-test-token');
await observer.locator('#auth-btn').click();
await observer.waitForFunction(() => document.querySelector('#session-status').textContent === 'Connected');
await page.locator('#session-rename').click();
await page.locator('#session-name-input').fill('Second workspace');
await page.locator('#session-rename-save').click();
await page.locator('#session-rename-dialog').waitFor({ state: 'hidden' });
await observer.waitForFunction(() => document.querySelector('#session-title').textContent.startsWith('Second workspace'));
await page.locator('#session-delete').click();
await page.locator('#confirm-ok').click();
await page.waitForFunction(() => document.querySelector('#vm-session-count').textContent === '0');
await observer.waitForURL((url) => url.hash === '#vm/vm-test/sessions');
assert.ok(await observer.locator('#session-terminal').isHidden());
await page.reload();
await page.waitForFunction(() => document.querySelector('#vm-session-count').textContent === '0');
assert.equal(await page.locator('.session-item').count(), 0);
assert.deepEqual(errors, []);
await observer.close();
console.log('Browser sessions passed: terminal/reconnect, VM isolation, persistent rename, active/ended deletion, cancellation, live browser updates, and mobile layout.');
} catch (error) {
if (page) console.error('Browser state:', await page.locator('#sessions-error').textContent(), await page.locator('#session-status').textContent());
console.error(logs);
throw error;
} finally {
await browser?.close();
server.kill('SIGTERM');
if (server.exitCode === null) await new Promise((resolve) => server.once('exit', resolve));
await rm(temporary, { recursive: true, force: true });
}
})().catch((error) => { console.error(error); process.exitCode = 1; });