mirror of
https://github.com/stablyai/orca.git
synced 2026-10-05 08:02:33 +00:00
* docs(agent-status): plan PR 1b at file level Names the five RuntimeAgentRowStore call sites and what each becomes, why terminalHandle has to be stamped before the store can go, and the one intended behavior change. * feat(agent-status): stamp the pane terminal handle on hook-server rows The runtime's retained row store carried the pty binding two readers need. Put that fact on the row that already owns the pane instead, resolved through the same lookup the renderer-facing IPC boundary runs, so the two surfaces cannot disagree about which terminal a pane is. Carried forward when a later write resolves no handle (only main's OSC parse can), and never persisted: a handle belongs to the runtime that issued it. * refactor(agent-status): route the session-tabs republish off the store `retain()` was not only a duplicate store: its boolean return was the signal that republished `session.tabs` for a status-only transition, which no title change covers (#7970). `hook-status-session-tabs-invalidation.ts` already mirrors that change set plus hook restore provenance, so route the signal off the store rather than keep a second comparator. Adds the status-drop arm a user dismissal emits, which the pane-clear fan-out deliberately skips — now load-bearing, because a dismissed row leaves the listing at once. Installed on both hosts. orcad had neither the OSC producer nor this signal, so its runtime observed agent status and published it nowhere; deleting the retained copy without wiring it would list no PTY agents there at all. * refactor(agent-status): delete the runtime's duplicate retained row store `RuntimeAgentRowStore` held the same payload the hook server already holds, so the same pane could legitimately read differently in the sidebar, in `worktree ps`, and on the phone. Both of its readers move onto the store's snapshot in `runtime-hook-agent-row-selection.ts`, and `collectRuntimeWorktreePtyAgentSources` loses the retained-versus-hook reconciliation that only existed because two stores could disagree. `ConnectedPtyEvidence` trades its flat pty-id set for `ptyIdByTerminalHandle`, which is how a row still resolves the connected PTY behind it — the working-terminal rollup's match key, and the last rescue for a row whose pane binding a controller incarnation nulled under it. The one intended behavior change: a row the user dismisses on the desktop leaves `worktree ps` and mobile at once instead of lingering until the pty exits. One store means one dismissal. The suites written against the retained store are rewired to a real AgentHookServer rather than deleted, so each still asserts the listing behavior it named. * docs(agent-status): record what PR 1b landed Past tense, plus two corrections to the plan: `terminalHandle` is not the pty id (they are different identifiers, and the explicit-status reader was already comparing against a real handle), and the legacy numeric pane key is a consequence the plan did not name. * fix(agent-status): harden single-store lifecycle * fix(agent-status): preserve mobile terminal rejoin * fix(agent-status): preserve unverifiable remote rows * fix(agent-status): own PTY row lifecycle in hook server * fix(agent-status): preserve state and renew freshness * fix(agent-status): ignore freshness for dismissed identity rows * fix(agent-status): fence orcad observed identities * fix(orcad): always release daemon adapter on cleanup * fix(agent-status): cover remint and headless lifecycle edges * fix agent status identity recovery gaps * fix(agent-status): suppress duplicate child-only row mutation * test(runtime): preserve hook store wiring in transcript harness --------- Co-authored-by: Merge Sim <sim@local>
482 lines
18 KiB
TypeScript
482 lines
18 KiB
TypeScript
import { execFileSync } from 'node:child_process'
|
|
import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import type { Page, TestInfo } from '@stablyai/playwright-test'
|
|
import { expect, test } from './helpers/orca-app'
|
|
import { loadWorktreesUntilPathsPresent } from './helpers/worktree-registration'
|
|
import {
|
|
ensureTerminalVisible,
|
|
getAllWorktreeIds,
|
|
switchToWorktree,
|
|
waitForActiveWorktree,
|
|
waitForSessionReady
|
|
} from './helpers/store'
|
|
import {
|
|
execInTerminal,
|
|
waitForActivePanePtyId,
|
|
waitForActiveTerminalManager,
|
|
waitForTerminalOutput
|
|
} from './helpers/terminal'
|
|
|
|
/**
|
|
* Worktree-switch first-paint budget.
|
|
*
|
|
* Why this exists: worktree-switch-responsiveness.spec.ts proves the click task
|
|
* stays short, and the reveal-convergence spec proves the buffer eventually
|
|
* matches. Neither covers the symptom users report — the revealed terminal is
|
|
* BLANK for a beat after the switch. This measures the phase that owns that
|
|
* beat: switch click -> revealed pane has painted its restored content.
|
|
*
|
|
* The scenario is the one that dominates at many-worktree scale: a switch to a
|
|
* worktree whose tabs are in the persisted session but have never been mounted
|
|
* in this renderer. Hot-retain only keeps 4 worktrees warm, so with hundreds of
|
|
* worktrees essentially every switch is this one. Reloading the renderer between
|
|
* rounds reproduces it exactly, at production parking timings.
|
|
*/
|
|
|
|
// Why 3: the field profile that motivated this budget has 449 worktrees whose
|
|
// median tab count is 2-3, so a 3-tab worktree is the switch users actually pay for.
|
|
const TABS_PER_WORKTREE = Number(process.env.ORCA_SWITCH_TABS ?? '3')
|
|
const SCROLLBACK_LINES = 1_500
|
|
// Budget: a switch has to look instant. Anything over this reads as a stall.
|
|
const FIRST_PAINT_BUDGET_MS = Number(process.env.ORCA_SWITCH_BUDGET_MS ?? '250')
|
|
// Why repeat: a single cold reveal on a loaded dev machine swings by tens of ms,
|
|
// which is the same order as the effect under test.
|
|
const SWITCH_SAMPLE_COUNT = Number(process.env.ORCA_SWITCH_ROUNDS ?? '5')
|
|
|
|
type SwitchSample = {
|
|
activationMs: number | null
|
|
paneMountedMs: number | null
|
|
contentRestoredMs: number | null
|
|
maxFrameGapMs: number
|
|
longTaskTotalMs: number
|
|
worstLongTaskMs: number
|
|
mountedAtActivation: number
|
|
settledPaneManagers: number
|
|
settledPanes: number
|
|
settledWebglContexts: number
|
|
}
|
|
|
|
type SwitchPaintProbe = {
|
|
t0: number
|
|
activationMs: number | null
|
|
paneMountedMs: number | null
|
|
contentRestoredMs: number | null
|
|
frames: number[]
|
|
longTasks: number[]
|
|
mountedAtActivation: number
|
|
stop: () => void
|
|
}
|
|
|
|
declare global {
|
|
var __switchPaintProbe: SwitchPaintProbe | undefined
|
|
}
|
|
|
|
async function ensureTabs(page: Page, worktreeId: string, marker: string): Promise<string[]> {
|
|
await switchToWorktree(page, worktreeId)
|
|
await ensureTerminalVisible(page)
|
|
const tabIds: string[] = []
|
|
for (let index = 0; index < TABS_PER_WORKTREE; index += 1) {
|
|
const tabId = await page.evaluate(
|
|
({ id, wanted }) => {
|
|
const state = window.__store!.getState()
|
|
const existing = state.tabsByWorktree[id] ?? []
|
|
const reuse = existing[wanted]
|
|
const tab = reuse ?? state.createTab(id, undefined, undefined, { activate: true })
|
|
state.setActiveTab(tab.id)
|
|
state.setActiveTabType('terminal')
|
|
return tab.id
|
|
},
|
|
{ id: worktreeId, wanted: index }
|
|
)
|
|
await waitForActiveTerminalManager(page, 30_000)
|
|
const ptyId = await waitForActivePanePtyId(page, 30_000)
|
|
const label = `${marker}_T${index}`
|
|
await execInTerminal(
|
|
page,
|
|
ptyId,
|
|
`for i in $(seq 1 ${SCROLLBACK_LINES}); do echo "${label}_$i ${'y'.repeat(48)}"; done; echo ${label}_READY`
|
|
)
|
|
await waitForTerminalOutput(page, `${label}_READY`, 60_000)
|
|
tabIds.push(tabId)
|
|
}
|
|
return tabIds
|
|
}
|
|
|
|
async function waitForUnmountedTabs(page: Page, tabIds: readonly string[]): Promise<boolean> {
|
|
return expect
|
|
.poll(
|
|
() =>
|
|
page.evaluate((ids) => ids.every((id) => window.__paneManagers?.has(id) !== true), tabIds),
|
|
{ timeout: 20_000, message: 'switch target still had mounted panes' }
|
|
)
|
|
.toBe(true)
|
|
.then(
|
|
() => true,
|
|
() => false
|
|
)
|
|
}
|
|
|
|
/** Tabs with a mounted pane, once the post-reveal warm-up has settled. */
|
|
async function waitForMountedTabs(page: Page, tabIds: readonly string[]): Promise<string[]> {
|
|
const read = () =>
|
|
page.evaluate(
|
|
(ids) => ids.filter((id) => window.__paneManagers?.has(id) === true).sort(),
|
|
[...tabIds]
|
|
)
|
|
await expect
|
|
.poll(async () => (await read()).length, {
|
|
timeout: 20_000,
|
|
message: 'activation-deferred tabs never mounted after the reveal'
|
|
})
|
|
.toBe(tabIds.length)
|
|
.catch(() => undefined)
|
|
return read()
|
|
}
|
|
|
|
async function measureSwitch(
|
|
page: Page,
|
|
targetWorktreeId: string,
|
|
targetTabIds: readonly string[]
|
|
): Promise<SwitchSample> {
|
|
await page.evaluate(
|
|
({ worktreeId, tabIds }) => {
|
|
const probe = {
|
|
t0: performance.now(),
|
|
activationMs: null as number | null,
|
|
paneMountedMs: null as number | null,
|
|
contentRestoredMs: null as number | null,
|
|
frames: [] as number[],
|
|
longTasks: [] as number[],
|
|
mountedAtActivation: 0,
|
|
stop: () => {}
|
|
}
|
|
globalThis.__switchPaintProbe = probe
|
|
let observer: PerformanceObserver | null = null
|
|
try {
|
|
observer = new PerformanceObserver((list) => {
|
|
for (const entry of list.getEntries()) {
|
|
probe.longTasks.push(entry.duration)
|
|
}
|
|
})
|
|
observer.observe({ entryTypes: ['longtask'] })
|
|
} catch {
|
|
/* longtask unsupported */
|
|
}
|
|
let running = true
|
|
const visibleTabId = () => {
|
|
const state = window.__store!.getState()
|
|
return state.activeWorktreeId === worktreeId && state.activeTabType === 'terminal'
|
|
? state.activeTabId
|
|
: (state.activeTabIdByWorktree?.[worktreeId] ?? null)
|
|
}
|
|
const tick = () => {
|
|
if (!running) {
|
|
return
|
|
}
|
|
const now = performance.now() - probe.t0
|
|
probe.frames.push(now)
|
|
const state = window.__store!.getState()
|
|
if (probe.activationMs === null && state.activeWorktreeId === worktreeId) {
|
|
probe.activationMs = now
|
|
// Why here and not at paint: this is the switch's own frame, before any
|
|
// idle admission can run, so it measures what the SWITCH mounted.
|
|
probe.mountedAtActivation = tabIds.filter(
|
|
(id) => window.__paneManagers?.has(id) === true
|
|
).length
|
|
}
|
|
const tabId = visibleTabId()
|
|
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
|
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
|
if (probe.paneMountedMs === null && pane?.container?.isConnected) {
|
|
probe.paneMountedMs = now
|
|
}
|
|
if (probe.contentRestoredMs === null && pane) {
|
|
// Restored = the revealed viewport carries real text rather than an
|
|
// empty grid. Read on a frame callback, so this is the frame the
|
|
// content became renderable — one frame ahead of the pixels, and not
|
|
// a pixel assertion. Both arms are measured identically.
|
|
const buffer = pane.terminal.buffer.active
|
|
let filledRows = 0
|
|
for (let row = 0; row < pane.terminal.rows; row += 1) {
|
|
const line = buffer.getLine(buffer.viewportY + row)
|
|
if (line && line.translateToString(true).trim().length > 0) {
|
|
filledRows += 1
|
|
}
|
|
}
|
|
if (filledRows >= Math.min(5, pane.terminal.rows)) {
|
|
probe.contentRestoredMs = now
|
|
}
|
|
}
|
|
requestAnimationFrame(tick)
|
|
}
|
|
requestAnimationFrame(tick)
|
|
probe.stop = () => {
|
|
running = false
|
|
try {
|
|
observer?.disconnect()
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
window.__store!.getState().setActiveWorktree(worktreeId)
|
|
},
|
|
{ worktreeId: targetWorktreeId, tabIds: [...targetTabIds] }
|
|
)
|
|
|
|
// Why poll rather than sample a fixed window: the measurement is "how long did
|
|
// the restore take", so the harness must outlast the slowest runner rather than
|
|
// give up at a deadline and report the reveal as never restoring.
|
|
await expect
|
|
.poll(() => page.evaluate(() => globalThis.__switchPaintProbe?.contentRestoredMs ?? null), {
|
|
timeout: 30_000,
|
|
message: 'revealed terminal never restored its content'
|
|
})
|
|
.not.toBeNull()
|
|
// Let the idle admission drain so the settled-resource readings are steady.
|
|
await page.waitForTimeout(2_000)
|
|
|
|
return page.evaluate(() => {
|
|
const probe = globalThis.__switchPaintProbe!
|
|
probe.stop()
|
|
let maxGap = 0
|
|
let previous = 0
|
|
for (const frame of probe.frames) {
|
|
maxGap = Math.max(maxGap, frame - previous)
|
|
previous = frame
|
|
}
|
|
let settledPanes = 0
|
|
let settledWebglContexts = 0
|
|
const managers = window.__paneManagers
|
|
for (const manager of managers?.values() ?? []) {
|
|
settledPanes += (manager.getPanes?.() ?? []).length
|
|
// Why diagnostics and not `pane.webglAddon`: getPanes() hands back a public
|
|
// projection that has no webglAddon field, so reading it is always falsy.
|
|
const diagnostics =
|
|
(
|
|
manager as { getRenderingDiagnostics?: () => { hasWebgl?: boolean }[] }
|
|
).getRenderingDiagnostics?.() ?? []
|
|
settledWebglContexts += diagnostics.filter((entry) => entry.hasWebgl === true).length
|
|
}
|
|
return {
|
|
settledPaneManagers: managers?.size ?? 0,
|
|
settledPanes,
|
|
settledWebglContexts,
|
|
activationMs: probe.activationMs,
|
|
paneMountedMs: probe.paneMountedMs,
|
|
contentRestoredMs: probe.contentRestoredMs,
|
|
maxFrameGapMs: +maxGap.toFixed(1),
|
|
longTaskTotalMs: +probe.longTasks.reduce((total, value) => total + value, 0).toFixed(1),
|
|
worstLongTaskMs: +probe.longTasks
|
|
.reduce((worst, value) => Math.max(worst, value), 0)
|
|
.toFixed(1),
|
|
mountedAtActivation: probe.mountedAtActivation
|
|
}
|
|
})
|
|
}
|
|
|
|
function report(label: string, sample: SwitchSample): string {
|
|
return [
|
|
`${label}:`,
|
|
` activation ${sample.activationMs?.toFixed(1) ?? 'n/a'}ms`,
|
|
` pane mounted ${sample.paneMountedMs?.toFixed(1) ?? 'n/a'}ms`,
|
|
` content restored ${sample.contentRestoredMs?.toFixed(1) ?? 'never'}ms`,
|
|
` max frame gap ${sample.maxFrameGapMs}ms`,
|
|
` long tasks total=${sample.longTaskTotalMs}ms worst=${sample.worstLongTaskMs}ms`,
|
|
` panes at switch ${sample.mountedAtActivation}/${TABS_PER_WORKTREE}`,
|
|
` settled resources managers=${sample.settledPaneManagers} panes=${sample.settledPanes} webgl=${sample.settledWebglContexts}`
|
|
].join('\n')
|
|
}
|
|
|
|
async function publish(testInfo: TestInfo, name: string, body: string): Promise<void> {
|
|
console.log(body)
|
|
await testInfo.attach(name, { body, contentType: 'text/plain' })
|
|
}
|
|
|
|
// Why 8 extra: hot-retain keeps the 4 most recently hidden worktrees mounted and
|
|
// exempts the last-active one, so a target only cold-parks once enough other
|
|
// worktrees have been visited after it. That is the steady state at field scale.
|
|
const FILLER_WORKTREE_COUNT = Number(process.env.ORCA_SWITCH_FILLER_WORKTREES ?? '8')
|
|
|
|
async function addFillerWorktrees(
|
|
page: Page,
|
|
testRepoPath: string
|
|
): Promise<{ ids: string[]; cleanup: () => void }> {
|
|
const parent = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-switch-paint-')))
|
|
const paths = Array.from({ length: FILLER_WORKTREE_COUNT }, (_, index) =>
|
|
path.join(parent, `filler-${index}`)
|
|
)
|
|
const removeAll = (): void => {
|
|
for (const worktreePath of paths) {
|
|
try {
|
|
execFileSync('git', ['worktree', 'remove', '--force', worktreePath], {
|
|
cwd: testRepoPath,
|
|
stdio: 'ignore'
|
|
})
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
}
|
|
rmSync(parent, { recursive: true, force: true })
|
|
}
|
|
// Why clean up before rethrowing: testRepoPath is worker-scoped and reused by
|
|
// later specs, so a half-built fixture would leak worktrees into them.
|
|
try {
|
|
for (const worktreePath of paths) {
|
|
execFileSync('git', ['worktree', 'add', '--detach', worktreePath, 'HEAD'], {
|
|
cwd: testRepoPath,
|
|
stdio: 'ignore'
|
|
})
|
|
}
|
|
} catch (error) {
|
|
removeAll()
|
|
throw error
|
|
}
|
|
try {
|
|
return await registerFillerWorktrees(page, testRepoPath, paths, removeAll)
|
|
} catch (error) {
|
|
removeAll()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async function registerFillerWorktrees(
|
|
page: Page,
|
|
testRepoPath: string,
|
|
paths: readonly string[],
|
|
cleanup: () => void
|
|
): Promise<{ ids: string[]; cleanup: () => void }> {
|
|
const repoId = await page.evaluate(
|
|
(repoPath) =>
|
|
window.__store!.getState().repos.find((repo) => repo.path === repoPath)?.id ?? null,
|
|
testRepoPath
|
|
)
|
|
if (!repoId) {
|
|
throw new Error(`seeded repo not registered: ${testRepoPath}`)
|
|
}
|
|
await loadWorktreesUntilPathsPresent(page, repoId, [...paths])
|
|
const ids = await page.evaluate(
|
|
({ id, wanted }) =>
|
|
(window.__store!.getState().worktreesByRepo[id] ?? [])
|
|
.filter((worktree) => wanted.includes(worktree.path))
|
|
.map((worktree) => worktree.id),
|
|
{ id: repoId, wanted: paths }
|
|
)
|
|
return { ids, cleanup }
|
|
}
|
|
|
|
function median(values: readonly number[]): number {
|
|
const sorted = [...values].sort((a, b) => a - b)
|
|
const middle = Math.floor(sorted.length / 2)
|
|
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
|
|
}
|
|
|
|
test.describe('Worktree switch first paint', () => {
|
|
test('repaints an unmounted worktree within the switch budget', async ({
|
|
orcaPage,
|
|
testRepoPath
|
|
}, testInfo) => {
|
|
test.setTimeout(900_000)
|
|
await waitForSessionReady(orcaPage)
|
|
await waitForActiveWorktree(orcaPage)
|
|
await ensureTerminalVisible(orcaPage)
|
|
|
|
const worktreeIds = await getAllWorktreeIds(orcaPage)
|
|
expect(worktreeIds.length).toBeGreaterThanOrEqual(2)
|
|
const [primaryId, targetId] = worktreeIds
|
|
const filler = await addFillerWorktrees(orcaPage, testRepoPath)
|
|
|
|
const samples: SwitchSample[] = []
|
|
const lines: string[] = []
|
|
try {
|
|
const targetTabIds = await ensureTabs(orcaPage, targetId, 'WTB')
|
|
|
|
// Give the filler worktrees persisted tabs without mounting them, so the
|
|
// store carries a field-scale tab population (the profile that motivated
|
|
// this budget has 846 tabs across 449 worktrees).
|
|
await orcaPage.evaluate(
|
|
({ ids, perWorktree }) => {
|
|
const state = window.__store!.getState()
|
|
for (const id of ids) {
|
|
const existing = state.tabsByWorktree[id] ?? []
|
|
for (let index = existing.length; index < perWorktree; index += 1) {
|
|
state.createTab(id)
|
|
}
|
|
}
|
|
},
|
|
{ ids: filler.ids, perWorktree: 2 }
|
|
)
|
|
|
|
for (let round = 0; round < SWITCH_SAMPLE_COUNT; round += 1) {
|
|
// Leave the primary active and let the session persist before reloading:
|
|
// startup restores the persisted active worktree, so this is what makes
|
|
// the target come back with tabs in the session and no pane ever mounted
|
|
// — the state every switch lands in once the worktree count exceeds the
|
|
// hot-retain working set.
|
|
await switchToWorktree(orcaPage, primaryId)
|
|
await ensureTerminalVisible(orcaPage)
|
|
await orcaPage.waitForTimeout(2_500)
|
|
await orcaPage.reload()
|
|
await waitForSessionReady(orcaPage)
|
|
await waitForActiveWorktree(orcaPage)
|
|
await ensureTerminalVisible(orcaPage)
|
|
await orcaPage.waitForTimeout(2_500)
|
|
const unmounted = await waitForUnmountedTabs(orcaPage, targetTabIds)
|
|
expect(unmounted, 'target worktree was already mounted before the switch').toBe(true)
|
|
|
|
const sample = await measureSwitch(orcaPage, targetId, targetTabIds)
|
|
samples.push(sample)
|
|
lines.push(report(`round ${round + 1} (target unmounted=${unmounted})`, sample))
|
|
|
|
// The half of the contract that keeps the speed-up free: the hidden tabs
|
|
// the switch skipped still end up mounted, so the next tab switch is as
|
|
// warm as it was before the reveal stopped mounting them up front.
|
|
const warmedTabIds = await waitForMountedTabs(orcaPage, targetTabIds)
|
|
expect(warmedTabIds, 'deferred tabs never joined the warm working set').toEqual(
|
|
[...targetTabIds].sort()
|
|
)
|
|
}
|
|
} finally {
|
|
filler.cleanup()
|
|
}
|
|
|
|
const restored = samples
|
|
.map((sample) => sample.contentRestoredMs)
|
|
.filter((value): value is number => value !== null)
|
|
expect(restored.length, 'revealed terminal never restored its content').toBe(samples.length)
|
|
const summary = [
|
|
`first activation -> ${TABS_PER_WORKTREE}-tab worktree, ${samples.length} rounds`,
|
|
` content restored: median=${median(restored).toFixed(1)}ms samples=${restored
|
|
.map((value) => value.toFixed(0))
|
|
.join(', ')}ms`,
|
|
` activation: median=${median(
|
|
samples.map((sample) => sample.activationMs ?? 0)
|
|
).toFixed(1)}ms`,
|
|
` panes at switch: ${samples.map((sample) => sample.mountedAtActivation).join(', ')}`,
|
|
` settled panes: ${samples.map((sample) => sample.settledPanes).join(', ')}`,
|
|
` settled webgl: ${samples.map((sample) => sample.settledWebglContexts).join(', ')}`,
|
|
'',
|
|
...lines
|
|
].join('\n')
|
|
await publish(testInfo, 'first-activation-switch.txt', summary)
|
|
|
|
for (const sample of samples) {
|
|
expect(
|
|
sample.mountedAtActivation,
|
|
'the switch mounted more than the pane the user is looking at'
|
|
).toBe(1)
|
|
}
|
|
// Why CI is exempt from the budget and not from the invariants: shared
|
|
// runners cannot hold a latency threshold, but "the switch mounted one pane"
|
|
// and "the warm set came back" are exact and are the real regression guards.
|
|
if (process.env.CI) {
|
|
console.log(
|
|
`[switch-budget] CI run, latency budget not enforced (median ${median(restored).toFixed(1)}ms)`
|
|
)
|
|
return
|
|
}
|
|
expect(median(restored)).toBeLessThanOrEqual(FIRST_PAINT_BUDGET_MS)
|
|
})
|
|
})
|