diff --git a/package.json b/package.json index 103c0ab8..0b9652c8 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "verify:mcp-spawn": "node scripts/verify-mcp-spawn.mjs", "release:mac": "npm run icons:macos && npm run relayfile-mount:install:release && RELAYFILE_MOUNT_INSTALL_SOURCE=release npm run build && electron-builder --mac --publish always", "test": "node --experimental-strip-types --no-warnings --test 'src/main/__tests__/*.test.ts'", + "test:fidelity": "npm run build:web && playwright test --config playwright.fidelity.config.ts", "test:stress": "npm run build:web && playwright test --config playwright.stress.config.ts", "test:redraw": "npm run build:web && playwright test --config playwright.redraw.config.ts", "personas:refresh": "npx --yes agentworkforce install @agentworkforce/persona-autonomous-actor --overwrite && npx --yes agentworkforce install @agentworkforce/persona-slack-comms --overwrite" diff --git a/playwright.fidelity.config.ts b/playwright.fidelity.config.ts new file mode 100644 index 00000000..4515da39 --- /dev/null +++ b/playwright.fidelity.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './tests/playwright', + testMatch: /fidelity-no-duplication\.spec\.ts/, + timeout: 90_000, + expect: { + timeout: 15_000 + }, + webServer: { + command: 'npx vite preview --config vite.web.config.ts --host 127.0.0.1 --port 4175', + url: 'http://127.0.0.1:4175', + reuseExistingServer: false, + timeout: 30_000 + }, + use: { + ...devices['Desktop Chrome'], + baseURL: 'http://127.0.0.1:4175', + viewport: { width: 1440, height: 960 }, + trace: 'retain-on-failure' + }, + reporter: [['list']] +}) diff --git a/src/renderer/src/lib/ipc-mock.ts b/src/renderer/src/lib/ipc-mock.ts index b23b7a0f..d6b60081 100644 --- a/src/renderer/src/lib/ipc-mock.ts +++ b/src/renderer/src/lib/ipc-mock.ts @@ -69,6 +69,7 @@ import type { UpdaterState, WorkforcePersona } from '@shared/types/ipc' +import { getTerminalRuntime } from '@/lib/terminal-runtime-registry' type BrokerEventLike = Record & { kind?: string @@ -125,6 +126,7 @@ export interface PearMockHarness { spawnAgents: (count: number, options?: { projectId?: string; channel?: string; namePrefix?: string }) => void openChannel: (projectId: string, channelName: string) => void openAgents: (projectId?: string) => void + getTerminalBufferText: (projectId: string, name: string) => string | null getState: () => { activeId: string | null agents: BrokerListAgent[] @@ -743,6 +745,17 @@ export const pearMockHarness: PearMockHarness = { const listeners = state.menuListeners.get('mock:open-agents') for (const listener of listeners || []) listener(projectId) }, + getTerminalBufferText: (projectId: string, name: string) => { + const runtime = getTerminalRuntime(key(projectId, name)) + if (!runtime) return null + + const buffer = runtime.term.buffer.active + const lines: string[] = [] + for (let index = 0; index < buffer.length; index += 1) { + lines.push(buffer.getLine(index)?.translateToString(true) ?? '') + } + return lines.join('\n') + }, getState: () => ({ activeId: state.activeId, agents: clone(state.agents), diff --git a/src/renderer/src/lib/terminal-runtime-registry.ts b/src/renderer/src/lib/terminal-runtime-registry.ts index bef72aab..f6a09981 100644 --- a/src/renderer/src/lib/terminal-runtime-registry.ts +++ b/src/renderer/src/lib/terminal-runtime-registry.ts @@ -241,6 +241,10 @@ export function hasTerminalRuntime(key: string): boolean { return runtimes.has(key) } +export function getTerminalRuntime(key: string): TerminalRuntime | null { + return runtimes.get(key)?.runtime ?? null +} + function createRuntime( key: string, opts: AcquireOptions, diff --git a/tests/playwright/fidelity-no-duplication.spec.ts b/tests/playwright/fidelity-no-duplication.spec.ts new file mode 100644 index 00000000..664d8412 --- /dev/null +++ b/tests/playwright/fidelity-no-duplication.spec.ts @@ -0,0 +1,309 @@ +import { expect, type Page, test } from '@playwright/test' + +const PROJECT_ID = 'mock-project' +const HIGH_RATE_TOTAL = 1_000 +const VARIANT_TOTAL = 700 +// This suite asserts final-state fidelity, not live throughput. Synthetic +// xterm drain can lag by a contiguous tail under high-rate bursts (for +// example 1000 canonical markers / 762 rendered after a 10s settle), so keep +// a finite deadline with enough headroom to catch permanent stalls without +// failing on natural xterm write drain. +const FINAL_DRAIN_DEADLINE_MS = 30_000 + +type TerminalLayout = 'tabs' | 'horizontal-split' | 'graph' + +type StreamSpec = { + name: string + markerPrefix: string + total: number +} + +const agentName = (prefix: string, index = 1): string => + `${prefix}-${String(index).padStart(4, '0')}` + +const marker = (prefix: string, index: number): string => + `[${prefix}-${String(index).padStart(4, '0')}]` + +async function bootWithAgents( + page: Page, + count: number, + prefix: string, + layout: TerminalLayout = 'tabs' +): Promise { + await page.addInitScript((initialLayout) => { + window.localStorage.setItem('pear-terminal-layout', initialLayout) + }, layout) + + await page.goto('/') + await expect(page.getByText('Mock Project')).toBeVisible() + + await page.evaluate(({ agentCount, namePrefix }) => { + const mock = window.__pearMock + if (!mock) throw new Error('window.__pearMock is not available') + mock.spawnAgents(agentCount, { projectId: 'mock-project', namePrefix }) + }, { agentCount: count, namePrefix: prefix }) + + const firstAgent = agentName(prefix) + await expect(page.locator(`[data-testid="terminal-instance"][data-agent-name="${firstAgent}"]`)).toBeVisible() + await expect.poll( + () => page.evaluate(({ projectId, name }) => + window.__pearMock?.getTerminalBufferText(projectId, name) ?? null, + { projectId: PROJECT_ID, name: firstAgent }), + { message: `terminal runtime mounted for ${firstAgent}` } + ).not.toBeNull() +} + +async function startStream(page: Page, streams: StreamSpec[], yieldEvery = 25): Promise { + await page.evaluate(({ projectId, streamSpecs, frameYieldEvery }) => { + const win = window as Window & { __fidelityStreamDone?: Promise } + const mock = window.__pearMock + if (!mock) throw new Error('window.__pearMock is not available') + + win.__fidelityStreamDone = (async () => { + const maxTotal = Math.max(...streamSpecs.map((stream) => stream.total)) + for (let index = 0; index < maxTotal; index += 1) { + for (const stream of streamSpecs) { + if (index < stream.total) { + const id = String(index).padStart(4, '0') + mock.injectPtyChunk(projectId, stream.name, `[${stream.markerPrefix}-${id}]\r\n`) + } + } + if (index % frameYieldEvery === 0) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + } + } + })() + }, { projectId: PROJECT_ID, streamSpecs: streams, frameYieldEvery: yieldEvery }) +} + +async function waitForStream(page: Page): Promise { + await page.evaluate(() => { + const win = window as Window & { __fidelityStreamDone?: Promise } + return win.__fidelityStreamDone + }) + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))) + await page.waitForTimeout(1_500) + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))) +} + +async function readMarkerStats( + page: Page, + name: string, + markerPrefix: string, + total: number +): Promise<{ + renderedCount: number + canonicalCount: number + missing: string[] + duplicates: string[] +}> { + return page.evaluate(({ projectId, agent, prefix, expectedTotal }) => { + const mock = window.__pearMock + const renderedText = mock?.getTerminalBufferText(projectId, agent) ?? '' + const state = mock?.getState() + const canonicalText = state?.ptyChunks[`${projectId}:${agent}`]?.join('') ?? '' + const markerPattern = /\[[a-z0-9-]+-\d{4}\]/g + const renderedMarkers = (renderedText.match(markerPattern) ?? []) + .filter((entry) => entry.startsWith(`[${prefix}-`)) + const canonicalMarkers = (canonicalText.match(markerPattern) ?? []) + .filter((entry) => entry.startsWith(`[${prefix}-`)) + const renderedSet = new Set(renderedMarkers) + const seen = new Set() + const duplicates: string[] = [] + for (const entry of renderedMarkers) { + if (seen.has(entry)) duplicates.push(entry) + seen.add(entry) + } + const missing: string[] = [] + for (let index = 0; index < expectedTotal; index += 1) { + const expected = `[${prefix}-${String(index).padStart(4, '0')}]` + if (!renderedSet.has(expected)) missing.push(expected) + } + return { + renderedCount: renderedMarkers.length, + canonicalCount: canonicalMarkers.length, + missing: missing.slice(0, 12), + duplicates: duplicates.slice(0, 12) + } + }, { projectId: PROJECT_ID, agent: name, prefix: markerPrefix, expectedTotal: total }) +} + +async function waitForFinalMarkerStats( + page: Page, + name: string, + markerPrefix: string, + total: number, + deadlineMs: number +): Promise<{ + finalStats: Awaited> + snapshots: Array>> + elapsedMs: number + deadlineMs: number +}> { + const snapshots: Array>> = [] + const startedAt = Date.now() + const deadline = startedAt + deadlineMs + let finalStats = await readMarkerStats(page, name, markerPrefix, total) + + while (true) { + const done = finalStats.canonicalCount === total && finalStats.renderedCount === total + if (snapshots.length < 8 || done || Date.now() >= deadline) { + snapshots.push(finalStats) + } + if (done) { + return { finalStats, snapshots, elapsedMs: Date.now() - startedAt, deadlineMs } + } + if (Date.now() >= deadline) { + return { finalStats, snapshots, elapsedMs: Date.now() - startedAt, deadlineMs } + } + await page.waitForTimeout(500) + finalStats = await readMarkerStats(page, name, markerPrefix, total) + } +} + +async function expectFidelity( + page: Page, + trigger: string, + name: string, + markerPrefix: string, + total: number, + deadlineMs = FINAL_DRAIN_DEADLINE_MS +): Promise { + const { finalStats, snapshots, elapsedMs } = await waitForFinalMarkerStats(page, name, markerPrefix, total, deadlineMs) + const diagnostic = `${trigger}: marker stats for ${name}; elapsedMs=${elapsedMs}; deadlineMs=${deadlineMs}; snapshots=${JSON.stringify(snapshots)}` + + expect(finalStats.canonicalCount, diagnostic).toBe(total) + expect(finalStats.renderedCount, diagnostic).toBe(total) + expect(finalStats.duplicates, diagnostic).toEqual([]) + expect(finalStats.missing, diagnostic).toEqual([]) + + const bufferText = await page.evaluate(({ projectId, agent }) => + window.__pearMock?.getTerminalBufferText(projectId, agent) ?? null, + { projectId: PROJECT_ID, agent: name }) + + expect(bufferText, `${trigger}: terminal buffer for ${name}`).not.toBeNull() + + const markers = (bufferText ?? '').match(/\[[a-z0-9-]+-\d{4}\]/g) ?? [] + const targetMarkers = markers.filter((entry) => entry.startsWith(`[${markerPrefix}-`)) + expect(targetMarkers.length, `${trigger}: marker count for ${name}`).toBe(total) + + const seen = new Set() + let prev = -1 + for (const entry of targetMarkers) { + expect(seen.has(entry), `${trigger}: duplicate marker ${entry} in ${name}`).toBe(false) + seen.add(entry) + + const parsed = entry.match(/\d{4}/) + expect(parsed, `${trigger}: malformed marker ${entry} in ${name}`).not.toBeNull() + const current = Number.parseInt(parsed![0], 10) + expect(current, `${trigger}: out-of-order marker ${entry} after ${prev} in ${name}`).toBeGreaterThan(prev) + prev = current + } + + expect(seen.has(marker(markerPrefix, 0)), `${trigger}: first marker missing for ${name}`).toBe(true) + expect(seen.has(marker(markerPrefix, total - 1)), `${trigger}: final marker missing for ${name}`).toBe(true) +} + +test.describe('terminal output fidelity under high-rate PTY streaming', () => { + test('terminal output matches injected chunks under high-rate streaming', async ({ page }) => { + await bootWithAgents(page, 1, 'fid') + + const name = agentName('fid') + await startStream(page, [{ name, markerPrefix: 'chunk', total: HIGH_RATE_TOTAL }]) + await waitForStream(page) + + await expectFidelity(page, 'high-rate stream', name, 'chunk', HIGH_RATE_TOTAL) + }) + + test('tab-switch during stream preserves output fidelity', async ({ page }) => { + await bootWithAgents(page, 2, 'tab') + + const first = agentName('tab', 1) + const second = agentName('tab', 2) + await startStream(page, [{ name: first, markerPrefix: 'tab-a', total: VARIANT_TOTAL }]) + + for (let index = 0; index < 8; index += 1) { + await page.locator('button').filter({ hasText: index % 2 === 0 ? second : first }).first().click() + await page.waitForTimeout(100) + } + await page.locator('button').filter({ hasText: first }).first().click() + await waitForStream(page) + + await expectFidelity(page, 'tab-switch during stream', first, 'tab-a', VARIANT_TOTAL) + }) + + test('split-pane transition during stream preserves output fidelity', async ({ page }) => { + await bootWithAgents(page, 2, 'split', 'tabs') + + const first = agentName('split', 1) + const second = agentName('split', 2) + await startStream(page, [ + { name: first, markerPrefix: 'split-a', total: VARIANT_TOTAL }, + { name: second, markerPrefix: 'split-b', total: VARIANT_TOTAL } + ]) + + await page.waitForTimeout(150) + await page.getByRole('button', { name: 'Show split terminal pages' }).click() + await expect(page.locator(`[data-testid="terminal-instance"][data-agent-name="${first}"]`)).toBeVisible() + await expect(page.locator(`[data-testid="terminal-instance"][data-agent-name="${second}"]`)).toBeVisible() + await waitForStream(page) + + await expectFidelity(page, 'split-pane transition during stream', first, 'split-a', VARIANT_TOTAL) + await expectFidelity(page, 'split-pane transition during stream', second, 'split-b', VARIANT_TOTAL) + }) + + test('window resize during stream preserves output fidelity', async ({ page }) => { + await bootWithAgents(page, 1, 'resize') + + const name = agentName('resize') + await startStream(page, [{ name, markerPrefix: 'resize-a', total: VARIANT_TOTAL }]) + + for (const viewport of [ + { width: 1200, height: 760 }, + { width: 1560, height: 980 }, + { width: 980, height: 720 }, + { width: 1440, height: 960 } + ]) { + await page.waitForTimeout(90) + await page.setViewportSize(viewport) + } + await waitForStream(page) + + await expectFidelity(page, 'window resize during stream', name, 'resize-a', VARIANT_TOTAL) + }) + + test('window focus events during stream preserve output fidelity', async ({ page }) => { + await bootWithAgents(page, 1, 'focus') + + const name = agentName('focus') + await startStream(page, [{ name, markerPrefix: 'focus-a', total: VARIANT_TOTAL }]) + + for (let index = 0; index < 6; index += 1) { + await page.waitForTimeout(80) + await page.evaluate(() => { + window.dispatchEvent(new Event('blur')) + document.dispatchEvent(new Event('visibilitychange')) + window.dispatchEvent(new Event('focus')) + }) + } + await waitForStream(page) + + await expectFidelity(page, 'window focus events during stream', name, 'focus-a', VARIANT_TOTAL) + }) + + test('runtime remount mid-stream preserves output fidelity', async ({ page }) => { + await bootWithAgents(page, 1, 'remount') + + const name = agentName('remount') + await startStream(page, [{ name, markerPrefix: 'remount-a', total: VARIANT_TOTAL }]) + + await page.waitForTimeout(120) + await page.getByRole('button', { name: 'Show agent graph' }).click() + await page.waitForTimeout(120) + await page.getByRole('button', { name: 'Show terminal tabs' }).click() + await expect(page.locator(`[data-testid="terminal-instance"][data-agent-name="${name}"]`)).toBeVisible() + await waitForStream(page) + + await expectFidelity(page, 'runtime remount mid-stream', name, 'remount-a', VARIANT_TOTAL) + }) +})