mirror of
https://github.com/stablyai/orca.git
synced 2026-10-04 16:02:08 +00:00
* fix(floating-workspace): keep agent launches from moving the main window's tab
Launching an agent from the floating workspace's "+" menu switched the main
window off whatever chat or editor tab it was showing and onto its terminals.
The main window's selection is supposed to move only for the worktree it is
showing: browser and editor tab creation, splits, moves and drops all check
`activeWorktreeId === worktreeId` before touching it. Two places did not:
- `launchAgentInNewTab` called `setActiveTabType('terminal')` without a
worktree, which targets the active worktree whatever worktree the launch
landed in.
- terminal `createTab` wrote the global `activeTabId` for a tab in any
worktree.
Both now follow the store rule. The launch still selects its tab within its
own worktree, which is what the floating panel renders.
The floating titlebar button had side-stepped this with an `activate: false`
opt-out plus manual selection. That opt-out had no other caller and is removed;
the button now launches and focuses like every other entry point.
* fix(tabs): scope the remaining launch surface writes to the launch's worktree
Three more launch paths create a terminal tab and then call
`setActiveTabType('terminal')` without a worktree, which targets whatever
worktree is active when the call runs rather than the one the tab landed in:
- the paired-host agent launch, after the host's asynchronous create
- Session History resume, which can target a worktree the user is not viewing
and activates it only afterwards
- sleeping-agent resume, which the activation gate runs after asynchronous
readiness checks, by which time the user may have moved to another worktree
Each now names its worktree, like the local agent launch. The new tab still
lands selected when the user switches to that worktree.
* test(tabs): pin the paired-host launch scope in its existing web-runtime test
* test(tabs): type the left-worktree resume fixture instead of casting it
* refactor(tabs): require the worktree that setActiveTabType applies to
`setActiveTabType(type, worktreeId?)` quietly fell back to the active
worktree when the caller left the worktree out. A caller acting on a tab in
another worktree (the floating workspace, a background launch, a reveal that
lands after an async step) therefore retyped whatever the main window was
showing. The launch paths fixed earlier in this branch were instances of that;
54 other callers still relied on the fallback.
The worktree is now a required argument (nullable only for the no-active-
worktree case), so every caller states which worktree it means and a new
unscoped call fails to compile. Each call site passes the worktree of the tab
it acts on; where that is by construction the active worktree (shortcuts,
palette, tab strip), the result is unchanged. `activateTabAndFocusPane`
resolves the tab's owning worktree the same way `setActiveTab` does.
End-to-end helpers that drive the store directly pass the active worktree,
which keeps their previous behaviour.
* fix(floating-workspace): let the floating New Terminal activate its own tab
The floating "+" New Terminal created its tab with `activate: false` and then
selected it with `activateTab`, because creating an active tab used to write
the main window's selected tab even for another worktree. `createTab` now
activates a tab only within its own worktree's group unless that worktree is
the one on screen, so the workaround is no longer needed.
Creating the tab active also moves the floating workspace's remembered tab to
the new one; before, it stayed on the previously selected floating tab, which
auto-acknowledge reads to decide which floating agent the user is looking at.
* refactor(floating-workspace): route every floating New Terminal through one creator
The floating "+" New Terminal had stopped deferring activation, but Cmd+T with the
floating panel focused still went through a separate creator that created the tab
inactive and activated it by hand, which left the floating workspace's remembered tab
on the previous tab. Both now call createFloatingWorkspaceTerminalTab, which creates
the tab active in its own group and focuses it.
* docs(tabs): say why an unowned tab id keeps the on-screen worktree scope
487 lines
18 KiB
TypeScript
487 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', window.__store?.getState().activeWorktreeId ?? null)
|
|
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]
|
|
}
|
|
|
|
// Linux needs a mapped window for animation frames after reload; run on an isolated display.
|
|
test.describe('Worktree switch first paint @headful', () => {
|
|
test.skip(
|
|
process.env.ORCA_BACKGROUND_LAUNCH === '1',
|
|
'First-paint measurement requires a mapped window'
|
|
)
|
|
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)
|
|
})
|
|
})
|