diff --git a/packages/app/e2e/agent-bottom-anchor.spec.ts b/packages/app/e2e/agent-bottom-anchor.spec.ts index aabed59c4..4aa855a81 100644 --- a/packages/app/e2e/agent-bottom-anchor.spec.ts +++ b/packages/app/e2e/agent-bottom-anchor.spec.ts @@ -123,7 +123,7 @@ test("sticky mode stays pinned through composer growth and viewport resize, but await expectNearBottom(page); await expect(page.getByTestId("scroll-to-bottom-button")).toHaveCount(0); - await page.setViewportSize({ width: 1040, height: 760 }); + await page.setViewportSize({ width: 820, height: 760 }); await expect(page.getByTestId(`workspace-tab-agent_${agent.id}`).first()).toBeVisible({ timeout: 30000, }); diff --git a/packages/app/e2e/helpers/agent-bottom-anchor.ts b/packages/app/e2e/helpers/agent-bottom-anchor.ts index 8bc497d35..92a2dc570 100644 --- a/packages/app/e2e/helpers/agent-bottom-anchor.ts +++ b/packages/app/e2e/helpers/agent-bottom-anchor.ts @@ -171,7 +171,23 @@ function getVisibleChatScroll(page: Page) { export async function readScrollMetrics(page: Page): Promise { return getVisibleChatScroll(page).evaluate((root: Element) => { - const scrollElement = root as HTMLElement; + const candidates = [root, ...Array.from(root.querySelectorAll("*"))] + .filter((element): element is HTMLElement => element instanceof HTMLElement) + .filter((element) => { + const tagName = element.tagName.toLowerCase(); + const isEditable = + tagName === "textarea" || + tagName === "input" || + element.getAttribute("contenteditable") === "true"; + return !isEditable && element.scrollHeight - element.clientHeight > 1; + }); + const scrollElement = + candidates.sort( + (left, right) => + right.scrollHeight - + right.clientHeight - + (left.scrollHeight - left.clientHeight) + )[0] ?? (root as HTMLElement); const offsetY = Math.max(0, scrollElement.scrollTop); const contentHeight = Math.max(0, scrollElement.scrollHeight); @@ -193,40 +209,36 @@ export async function readScrollMetrics(page: Page): Promise { export async function scrollUpFromBottom(page: Page, pixels: number): Promise { const scrollViewport = getVisibleChatScroll(page); await expect(scrollViewport).toHaveCount(1, { timeout: 30000 }); + const targetDistanceFromBottom = Math.max( + NEAR_BOTTOM_THRESHOLD_PX + 24, + Math.max(0, pixels) + ); await scrollViewport.evaluate( - (root: Element, amount: number) => { - const scrollElement = root as HTMLElement; - - const bottomOffset = Math.max( - 0, - scrollElement.scrollHeight - scrollElement.clientHeight - ); - scrollElement.scrollTop = Math.max(0, bottomOffset - amount); + (root: Element, input: { targetDistanceFromBottom: number }) => { + const candidates = [root, ...Array.from(root.querySelectorAll("*"))] + .filter((element): element is HTMLElement => element instanceof HTMLElement) + .filter((element) => { + const tagName = element.tagName.toLowerCase(); + const isEditable = + tagName === "textarea" || + tagName === "input" || + element.getAttribute("contenteditable") === "true"; + return !isEditable && element.scrollHeight - element.clientHeight > 1; + }); + const scrollElement = + candidates.sort( + (left, right) => + right.scrollHeight - + right.clientHeight - + (left.scrollHeight - left.clientHeight) + )[0] ?? (root as HTMLElement); + const maxOffset = Math.max(0, scrollElement.scrollHeight - scrollElement.clientHeight); + const nextOffset = Math.max(0, maxOffset - input.targetDistanceFromBottom); + scrollElement.scrollTop = nextOffset; scrollElement.dispatchEvent(new Event("scroll", { bubbles: true })); }, - pixels + { targetDistanceFromBottom } ); - - if ((await readScrollMetrics(page)).distanceFromBottom > NEAR_BOTTOM_THRESHOLD_PX) { - return; - } - - const box = await scrollViewport.boundingBox(); - if (!box) { - return; - } - await page.mouse.move(box.x + box.width / 2, box.y + Math.min(box.height / 2, 24)); - - let remaining = Math.max(0, pixels); - while (remaining > 0) { - const delta = Math.min(240, remaining); - await page.mouse.wheel(0, -delta); - remaining -= delta; - - if ((await readScrollMetrics(page)).distanceFromBottom > NEAR_BOTTOM_THRESHOLD_PX) { - return; - } - } } export async function waitForAgentReady(page: Page, expectedTailText?: string): Promise { diff --git a/packages/cli/tests/03-daemon.test.ts b/packages/cli/tests/03-daemon.test.ts index 1f3a5218e..b745c8786 100644 --- a/packages/cli/tests/03-daemon.test.ts +++ b/packages/cli/tests/03-daemon.test.ts @@ -15,12 +15,10 @@ */ import assert from 'node:assert' -import { $ } from 'zx' import { mkdtemp, rm } from 'fs/promises' import { tmpdir } from 'os' import { join } from 'path' - -$.verbose = false +import { runLocalPaseo } from './helpers/local-cli.ts' console.log('=== Daemon Commands ===\n') @@ -28,11 +26,15 @@ console.log('=== Daemon Commands ===\n') const port = 10000 + Math.floor(Math.random() * 50000) const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-')) +function daemonCommand(args: string[]) { + return runLocalPaseo(['daemon', ...args], { PASEO_HOME: paseoHome }) +} + try { // Test 1: daemon --help shows subcommands { console.log('Test 1: daemon --help shows subcommands') - const result = await $`npx paseo daemon --help`.nothrow() + const result = await runLocalPaseo(['daemon', '--help']) assert.strictEqual(result.exitCode, 0, 'daemon --help should exit 0') assert(result.stdout.includes('start'), 'help should mention start') assert(result.stdout.includes('status'), 'help should mention status') @@ -45,8 +47,7 @@ try { // Test 2: daemon pair works without daemon process { console.log('Test 2: daemon pair prints local pairing URL') - const result = - await $`PASEO_HOME=${paseoHome} npx paseo daemon pair`.nothrow() + const result = await daemonCommand(['pair']) assert.strictEqual(result.exitCode, 0, 'daemon pair should succeed') assert(result.stdout.includes('Scan to pair:'), 'output should include scan header') assert(result.stdout.includes('#offer='), 'output should include pairing offer fragment') @@ -56,8 +57,7 @@ try { // Test 3: daemon status reports stopped when daemon not running { console.log('Test 3: daemon status reports stopped when not running') - const result = - await $`PASEO_HOME=${paseoHome} npx paseo daemon status`.nothrow() + const result = await daemonCommand(['status']) assert.strictEqual(result.exitCode, 0, 'status should succeed when daemon is stopped') const output = result.stdout.toLowerCase() assert(output.includes('status'), 'status table should include Status row') @@ -68,8 +68,7 @@ try { // Test 4: daemon pair --json outputs valid JSON { console.log('Test 4: daemon pair --json outputs JSON') - const result = - await $`PASEO_HOME=${paseoHome} npx paseo daemon pair --json`.nothrow() + const result = await daemonCommand(['pair', '--json']) assert.strictEqual(result.exitCode, 0, 'daemon pair --json should succeed') const pairing = JSON.parse(result.stdout) assert.strictEqual(pairing.relayEnabled, true, 'pairing should report relay enabled') @@ -81,8 +80,7 @@ try { // Test 5: daemon status --json outputs valid JSON { console.log('Test 5: daemon status --json outputs JSON') - const result = - await $`PASEO_HOME=${paseoHome} npx paseo daemon status --json`.nothrow() + const result = await daemonCommand(['status', '--json']) assert.strictEqual(result.exitCode, 0, '--json status should succeed') const status = JSON.parse(result.stdout) assert.strictEqual(typeof status.serverId, 'string', 'json status should include serverId') @@ -95,8 +93,7 @@ try { // Test 6: daemon stop handles daemon not running gracefully { console.log('Test 6: daemon stop handles daemon not running') - const result = - await $`PASEO_HOME=${paseoHome} npx paseo daemon stop`.nothrow() + const result = await daemonCommand(['stop']) // Stop should succeed even if daemon is not running (idempotent). assert.strictEqual(result.exitCode, 0, 'stop should succeed when daemon not running') const output = result.stdout + result.stderr @@ -110,18 +107,17 @@ try { // Test 7: daemon restart starts daemon and can be stopped { console.log('Test 7: daemon restart starts daemon and can be stopped') - const result = - await $`PASEO_HOME=${paseoHome} npx paseo daemon restart --port ${String(port)}`.nothrow() + const result = await daemonCommand(['restart', '--port', String(port)]) assert.strictEqual(result.exitCode, 0, 'restart should succeed even when previously stopped') assert(result.stdout.toLowerCase().includes('restarted'), 'output should report restart') - const cleanup = await $`PASEO_HOME=${paseoHome} npx paseo daemon stop --force`.nothrow() + const cleanup = await daemonCommand(['stop', '--force']) assert.strictEqual(cleanup.exitCode, 0, 'cleanup stop should succeed after restart') console.log('✓ daemon restart starts and stop cleanup succeeds\n') } } finally { // Best-effort daemon cleanup in case assertions fail before explicit stop. - await $`PASEO_HOME=${paseoHome} npx paseo daemon stop --force`.nothrow() + await daemonCommand(['stop', '--force']) // Clean up temp directory await rm(paseoHome, { recursive: true, force: true }) }