orca/tests/e2e/git-no-upstream-polling-chu...

220 lines
7.2 KiB
TypeScript

import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs'
import type { Page, TestInfo } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
// Repro command:
// SKIP_BUILD=1 pnpm exec playwright test tests/e2e/git-no-upstream-polling-churn.spec.ts --config tests/playwright.config.ts --project electron-headless --reporter=json
// Trigger: active worktree branch "Initi-Project" has no configured upstream
// and no same-name origin ref, then the source-control poller runs for 7.2s.
type DiagnosticsStatus = {
localFileEnabled: boolean
bundleEnabled: boolean
traceFilePath: string
}
type RendererTimerMeasurement = {
elapsedMs: number
maxTimerDriftMs: number
samples: number
}
type GitProbeFailureCounts = {
noConfiguredUpstreamFailures: number
missingSameNameOriginFailures: number
}
const ISSUE_BRANCH_NAME = 'Initi-Project'
const POLLING_WINDOW_MS = 7_200
const TIMER_SAMPLE_MS = 16
const MAX_RENDERER_TIMER_DRIFT_MS = 500
function prepareNoUpstreamBranch(repoPath: string): void {
execFileSync('git', ['checkout', '-B', ISSUE_BRANCH_NAME], { cwd: repoPath, stdio: 'pipe' })
try {
execFileSync('git', ['branch', '--unset-upstream', ISSUE_BRANCH_NAME], {
cwd: repoPath,
stdio: 'pipe'
})
} catch {
// No upstream is the fixture state we need for the #4559 log pattern.
}
try {
execFileSync('git', ['update-ref', '-d', `refs/remotes/origin/${ISSUE_BRANCH_NAME}`], {
cwd: repoPath,
stdio: 'pipe'
})
} catch {
// Missing same-name origin ref is the fixture state we need.
}
}
async function selectRepoForActivePolling(
page: Page,
repoPath: string,
worktreePath: string
): Promise<void> {
await page.evaluate(
async ({ targetRepoPath, targetWorktreePath }) => {
const store = window.__store
if (!store) {
throw new Error('Expected e2e store to be exposed')
}
let state = store.getState()
const repo = state.repos.find((candidate) => candidate.path === targetRepoPath)
if (!repo) {
throw new Error(`Expected repo to be loaded: ${targetRepoPath}`)
}
await state.fetchWorktrees(repo.id)
state = store.getState()
const worktrees = Object.values(state.worktreesByRepo).flat()
const worktree = worktrees.find((candidate) => candidate.path === targetWorktreePath)
if (!worktree) {
throw new Error(
`Expected active-polling worktree to exist: ${targetWorktreePath}; saw ${worktrees
.map((candidate) => candidate.path)
.join(', ')}`
)
}
state.setActiveWorktree(worktree.id)
state.setRightSidebarOpen(true)
state.setRightSidebarTab('source-control')
},
{ targetRepoPath: repoPath, targetWorktreePath: worktreePath }
)
}
async function readDiagnosticsStatus(page: Page): Promise<DiagnosticsStatus> {
return page.evaluate(() => window.api.diagnostics.getStatus() as Promise<DiagnosticsStatus>)
}
function clearTraceFile(diagnostics: DiagnosticsStatus): void {
if (existsSync(diagnostics.traceFilePath)) {
writeFileSync(diagnostics.traceFilePath, '', 'utf8')
}
for (let i = 1; i < 10; i++) {
const rotatedPath = `${diagnostics.traceFilePath}.${i}`
if (existsSync(rotatedPath)) {
unlinkSync(rotatedPath)
}
}
}
async function flushTraceFile(page: Page, diagnostics: DiagnosticsStatus): Promise<void> {
if (diagnostics.bundleEnabled) {
await page.evaluate(() => window.api.diagnostics.collectBundle(1))
return
}
await page.waitForTimeout(500)
}
async function measureRendererDuringPolling(page: Page): Promise<RendererTimerMeasurement> {
return page.evaluate(
async ({ pollWindowMs, sampleMs }) => {
let maxTimerDriftMs = 0
let samples = 0
let lastTick = performance.now()
const startedAt = lastTick
const timer = window.setInterval(() => {
const now = performance.now()
maxTimerDriftMs = Math.max(maxTimerDriftMs, now - lastTick - sampleMs)
lastTick = now
samples += 1
}, sampleMs)
await new Promise((resolve) => window.setTimeout(resolve, pollWindowMs))
window.clearInterval(timer)
return {
elapsedMs: performance.now() - startedAt,
maxTimerDriftMs,
samples
}
},
{ pollWindowMs: POLLING_WINDOW_MS, sampleMs: TIMER_SAMPLE_MS }
)
}
function readGitProbeFailureCounts(traceFilePath: string, repoPath: string): GitProbeFailureCounts {
if (!existsSync(traceFilePath)) {
return { noConfiguredUpstreamFailures: 0, missingSameNameOriginFailures: 0 }
}
const counts = { noConfiguredUpstreamFailures: 0, missingSameNameOriginFailures: 0 }
for (const line of readFileSync(traceFilePath, 'utf8').split(/\r?\n/)) {
if (!line.trim()) {
continue
}
let record: {
name?: string
attributes?: { cwd?: string }
exit?: { _tag?: string; cause?: unknown }
}
try {
record = JSON.parse(line)
} catch {
continue
}
if (
record.name !== 'git.exec' ||
record.attributes?.cwd !== repoPath ||
record.exit?._tag !== 'Failure'
) {
continue
}
const cause = String(record.exit.cause ?? '')
if (cause.includes('git rev-parse --abbrev-ref HEAD@{u}')) {
counts.noConfiguredUpstreamFailures += 1
}
if (cause.includes(`git rev-parse --verify --quiet refs/remotes/origin/${ISSUE_BRANCH_NAME}`)) {
counts.missingSameNameOriginFailures += 1
}
}
return counts
}
function annotatePolling(
testInfo: TestInfo,
measurement: RendererTimerMeasurement,
counts: GitProbeFailureCounts
): void {
testInfo.annotations.push({
type: 'git-no-upstream-polling-repro',
description: `elapsed=${measurement.elapsedMs.toFixed(1)}ms maxTimerDrift=${measurement.maxTimerDriftMs.toFixed(
1
)}ms samples=${measurement.samples} noUpstreamFailures=${
counts.noConfiguredUpstreamFailures
} missingOriginFailures=${counts.missingSameNameOriginFailures}`
})
}
test.describe('Git no-upstream polling churn repro', () => {
test('active worktree polling does not repeatedly retry stable no-upstream probes', async ({
orcaPage,
testRepoPath
}, testInfo) => {
const repoPath = realpathSync(testRepoPath)
prepareNoUpstreamBranch(repoPath)
await selectRepoForActivePolling(orcaPage, testRepoPath, repoPath)
const diagnostics = await readDiagnosticsStatus(orcaPage)
test.skip(!diagnostics.localFileEnabled, 'local diagnostic traces are disabled')
clearTraceFile(diagnostics)
const measurement = await measureRendererDuringPolling(orcaPage)
await flushTraceFile(orcaPage, diagnostics)
const counts = readGitProbeFailureCounts(diagnostics.traceFilePath, repoPath)
annotatePolling(testInfo, measurement, counts)
expect(measurement.maxTimerDriftMs).toBeLessThan(MAX_RENDERER_TIMER_DRIFT_MS)
// Why: the #4559 trace showed these stable negative upstream probes being
// retried every poll. Under parallel e2e load one in-flight refresh can
// overlap the trace reset, but the count should not keep climbing.
expect(counts.noConfiguredUpstreamFailures).toBeLessThanOrEqual(2)
expect(counts.missingSameNameOriginFailures).toBeLessThanOrEqual(2)
})
})