Files
apes-sidebar/tests/e2e/zz-expand-refresh.e2e.ts
T

378 lines
18 KiB
TypeScript

/**
* Expand-refresh lane: a folder toggle must not rebuild the tree.
*
* Reported symptom: "expanding a folder makes the whole tree refresh over and
* over". Two independent defects produced it, and neither is visible in jsdom:
*
* 1. the plugin's native surface re-created the `files` takeover's slot
* registration on EVERY store write, and the store notifies on each
* expand/collapse. Replacing a slot registration replaces the host's slot
* entry, so the native tab BODY was unmounted and remounted: the explorer
* lost its level cache, its scroll position and its directory watcher, and
* re-listed the whole visible set every time a folder was toggled;
* 2. a live-refresh notice dropped the cached level before re-listing it, so
* the rows of a directory that changed on disk were replaced by a loading
* placeholder and rebuilt.
*
* This lane is the real-browser counterpart of the two unit guards
* (tests/native-surface.spec.ts, tests/file-tree-batch-load.spec.tsx): jsdom
* cannot see the host's slot machinery, so only a mounted `dsh web` can prove
* the body survives a toggle. It drives the plugin's own Files tab in DSH's
* native right Sidebar over a workspace with a 40-child directory and records,
* per phase, every `fs.trees`/`fs.tree` request with its payload paths, every
* `/sidebar/ws/fs-watch` frame, the tree-body replacements and an rAF census of
* the live rows.
*
* The gate:
* A1 the first request after a toggle names the TOGGLED level only
* (asking for [root, ...expanded] is the empty-cache signature of a
* fresh mount);
* A2 the tree body survives every toggle (no unmount/remount, no reopened
* watch socket);
* A3 rows that are on screen never drop to zero — not on a toggle and not
* when a directory changes behind the plugin.
*
* Phases wait for QUIESCENCE (no new request, frame or DOM batch for a quiet
* window) rather than a fixed sleep, so a slow machine widens the window
* instead of racing it.
*
* NOTE: this lane seeds its own workspace + session, and DSH renders the
* session that is current — so it runs LAST (the `zz-` prefix) for the same
* reason the mount lane runs first: a later lane's seeding must not change what
* an earlier lane's tree shows.
*/
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
import { PAGE_URL, createHostApi, hostRpc, sendFirstMessage } from './host'
/** This lane's own workspace: a 40-child directory plus a small sibling. */
const WORKSPACE_PATH = process.env.DSH_E2E_EXPAND_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-expand-probe')
/** The big directory's name and its child count (the reader's scenario). */
const BIG = 'big'
const BIG_CHILDREN = 40
/** Quiescence: how long nothing may happen before a phase is read. */
const QUIET_MS = 500
/** Minimum reaction window: the watcher debounce (150ms) plus the round trip. */
const MIN_PHASE_MS = 800
const QUIET_MAX_MS = 4_000
let api: APIRequestContext
function seedWorkspace(): void {
// The lane owns this directory and every run starts from the same tree: a
// leftover file from an earlier run would already be listed, so the
// behind-the-plugin write below would change nothing on screen.
rmSync(WORKSPACE_PATH, { recursive: true, force: true })
mkdirSync(join(WORKSPACE_PATH, BIG), { recursive: true })
mkdirSync(join(WORKSPACE_PATH, 'bravo'), { recursive: true })
for (let i = 0; i < BIG_CHILDREN; i++) {
writeFileSync(join(WORKSPACE_PATH, BIG, `file-${String(i).padStart(2, '0')}.txt`), `child ${i}\n`)
}
writeFileSync(join(WORKSPACE_PATH, 'bravo', 'z.txt'), 'z\n')
writeFileSync(join(WORKSPACE_PATH, 'top.txt'), 'top\n')
}
test.beforeAll(async () => {
api = await createHostApi()
seedWorkspace()
const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(api, 'workspace.create', { path: WORKSPACE_PATH })
await hostRpc(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId })
})
test.afterAll(async () => { await api?.dispose() })
async function dismissOnboarding(page: Page): Promise<void> {
try {
await expect
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
.toBeGreaterThan(0)
} catch { /* a build without onboarding proceeds straight to the tree */ }
for (let round = 0; round < 8; round++) {
let dismissed = false
for (const name of ['Continue', 'Configure later']) {
const button = page.getByRole('button', { name, exact: true }).first()
if ((await button.count()) === 0) continue
try { await button.click({ timeout: 4_000 }); dismissed = true; await page.waitForTimeout(1_000) } catch { /* masked */ }
}
if (!dismissed) break
}
}
/**
* Install the census: follow the pane's tree body (through a replacement, so a
* remount is recorded without zeroing the measurement), count — every frame —
* the live rows under the big directory, and log every document-level batch
* that removed a native tab host or a whole run of rows.
*/
const INSTALL_CENSUS = `(() => {
const bodies = () => [...document.querySelectorAll('[class*="explorerBody"]')]
const findTree = () => bodies().find(el => el.getBoundingClientRect().height > 0
&& [...el.querySelectorAll('[class*="explorerName"]')].some(span => span.textContent === '${BIG}'))
const first = findTree()
if (first === undefined) return 'no tree body'
first.setAttribute('data-expand-probe', 'target')
const state = {
changes: [], batches: [], added: 0, removed: 0,
replacedBodies: 0, killedRows: 0, marks: [], minBig: Infinity, sawBig: 0, emptyFrames: 0,
}
let seq = 0
const seen = new Map()
const census = () => {
const now = Math.round(performance.now())
const live = bodies().filter(el => el.getBoundingClientRect().height > 0 || el.querySelector('[class*="explorerRow"]'))
for (const el of live) if (!seen.has(el)) { seq += 1; seen.set(el, seq); state.marks.push({ t: now, ev: 'tree-body-mount', seq }) }
for (const [el, id] of [...seen]) if (!el.isConnected) { seen.delete(el); state.marks.push({ t: now, ev: 'tree-body-unmount', seq: id }) }
const current = first.isConnected ? first : findTree()
const bigRows = current === undefined ? -1 : current.querySelectorAll('[title*="/${BIG}/"]').length
const rows = current === undefined ? -1 : current.querySelectorAll('[class*="explorerRow"]').length
state.changes.push({ t: now, alive: first.isConnected ? 1 : 0, bodies: live.length, rows, bigRows })
// The blanking detector: the low only counts from the moment rows are up
// (an empty level before its first listing is not a blank frame). Collapsing
// the folder legitimately empties it, so only the phases that keep it open
// assert on this.
if (bigRows > 0) state.sawBig = 1
if (state.sawBig === 1 && bigRows >= 0) state.minBig = Math.min(state.minBig, bigRows)
if (state.sawBig === 1 && bigRows === 0) state.emptyFrames += 1
}
new MutationObserver(records => {
let added = 0, removed = 0, killedRows = 0, killedBodies = 0
for (const record of records) {
added += record.addedNodes.length
removed += record.removedNodes.length
for (const node of record.removedNodes) {
if (!(node instanceof Element)) continue
const cls = typeof node.className === 'string' ? node.className : ''
if (cls.includes('nativeTabHost')) killedBodies += 1
killedRows += node.querySelectorAll('[class*="explorerRow"]').length
}
}
state.added += added
state.removed += removed
state.killedRows += killedRows
state.replacedBodies += killedBodies
// A placeholder that is not itself a row marks the frame a level was blanked.
if (killedRows > 0) state.emptyFrames += 1
state.batches.push({ t: Math.round(performance.now()), added, removed, killedRows, killedBodies })
census()
}).observe(document.body, { childList: true, subtree: true })
const loop = () => { census(); requestAnimationFrame(loop) }
requestAnimationFrame(loop)
census()
window.__census = { state }
return 'installed'
})()`
test('a folder toggle re-lists that folder and nothing else', async ({ page }) => {
// ── Node-side recorders (armed before navigation: the socket opens on mount) ──
interface Req { t: number; kind: string; paths: string[] }
const requests: Req[] = []
interface Sock { id: number; sent: string[]; recv: string[] }
const sockets: Sock[] = []
const t0 = Date.now()
const rel = (): number => Date.now() - t0
page.on('request', (request) => {
const url = request.url()
const kind = url.includes('/sidebar/api/fs.trees') ? 'fs.trees'
: url.includes('/sidebar/api/fs.tree') ? 'fs.tree'
: /\/sidebar\/api\/git\./.test(url) ? 'git' : null
if (kind === null) return
let paths: string[] = []
const body = request.postData()
if (body !== null) {
try {
const parsed = JSON.parse(body) as { paths?: string[]; path?: string }
paths = parsed.paths ?? (parsed.path === undefined ? [] : [parsed.path])
} catch { /* non-JSON payload: leave the paths empty */ }
}
requests.push({ t: rel(), kind, paths })
})
page.on('websocket', (ws) => {
if (!ws.url().includes('/sidebar/ws/fs-watch')) return
const sock: Sock = { id: sockets.length + 1, sent: [], recv: [] }
sockets.push(sock)
ws.on('framesent', (frame) => sock.sent.push(String(frame.payload)))
ws.on('framereceived', (frame) => sock.recv.push(String(frame.payload)))
})
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
await expect(page.locator('[data-dsh-apes-sidebar]')).toBeAttached({ timeout: 90_000 })
await dismissOnboarding(page)
await sendFirstMessage(page)
const pane = page.locator('[data-sidebar-right-panel]')
await page.locator('[data-sidebar-right-expand]').first().click()
await expect(pane).toBeVisible({ timeout: 90_000 })
const addTab = page.locator('[data-dockkit-add-tab]').first()
if (await page.locator('[data-sidebar-right-guide]').count() === 0) await addTab.click()
await page.locator('[data-sidebar-right-guide-entry="files"]').click()
const dirRow = (name: string) => pane.locator(`[class*="explorerDir"]:has([class*="explorerName"]:text-is("${name}"))`)
await expect(dirRow(BIG), 'the seeded big directory must be listed').toHaveCount(1, { timeout: 60_000 })
await expect(
pane.locator('[role="button"][title$="top.txt"]:visible'),
'the root level must be listed before the toggle',
).toHaveCount(1, { timeout: 60_000 })
const installed = await page.evaluate(INSTALL_CENSUS)
expect(installed, 'the tree body must exist for the census').toBe('installed')
/** A phase's readings, all read at once at the end of the phase. */
interface Phase {
phase: string
requests: string[]
sockets: number
wsFrames: number
added: number
removed: number
killedRows: number
treeBodyReplaced: number
emptyFrames: number
minBigRows: number
transitions: unknown[]
}
const phases: Phase[] = []
/** Counters the quiescence wait compares: requests, frames and DOM batches. */
const activity = (): number =>
requests.length + sockets.reduce((sum, s) => sum + s.sent.length + s.recv.length, 0) + domBatch
let domBatch = 0
const runPhase = async (phase: string, action: (() => Promise<void>) | null): Promise<Phase> => {
const reqBase = requests.length
const sockBase = sockets.length
await page.evaluate(() => {
const probe = (window as unknown as { __census?: { state: Record<string, unknown> } }).__census
if (probe === undefined) return
Object.assign(probe.state, {
changes: [], batches: [], marks: [], added: 0, removed: 0, replacedBodies: 0, killedRows: 0,
minBig: Infinity, sawBig: 0, emptyFrames: 0,
})
})
if (action !== null) await action()
// Wait for quiescence instead of a fixed sleep: the phase ends when nothing
// (network, socket, DOM) has moved for QUIET_MS, capped so a watcher loop
// cannot hang the lane — a loop is exactly what the gate must catch.
const started = Date.now()
let last = activity()
let quietSince = Date.now()
while (Date.now() - started < QUIET_MAX_MS) {
await page.waitForTimeout(50)
const seen = await page.evaluate(() => {
const probe = (window as unknown as { __census?: { state: { batches: unknown[] } } }).__census
return probe?.state.batches.length ?? 0
})
domBatch = seen
const now = activity()
if (now !== last) { last = now; quietSince = Date.now(); continue }
if (Date.now() - started >= MIN_PHASE_MS && Date.now() - quietSince >= QUIET_MS) break
}
const sample = await page.evaluate(() => {
const probe = (window as unknown as { __census?: { state: Record<string, unknown> } }).__census
if (probe === undefined) return null
const s = probe.state as unknown as {
changes: Array<{ t: number; alive: number; bodies: number; rows: number; bigRows: number }>
marks: Array<{ t: number; ev: string; seq: number }>
added: number; removed: number; replacedBodies: number; killedRows: number
minBig: number; emptyFrames: number
}
// Collapse the rAF census to its transitions (what changed on screen).
const transitions: typeof s.changes = []
let lastKey = ''
for (const entry of s.changes) {
const key = `${entry.alive}/${entry.bodies}/${entry.rows}/${entry.bigRows}`
if (key === lastKey) continue
lastKey = key
transitions.push(entry)
}
return {
added: s.added, removed: s.removed, killedRows: s.killedRows, emptyFrames: s.emptyFrames,
minBig: s.minBig === Infinity ? -1 : s.minBig,
remounts: s.marks.filter(mark => mark.ev === 'tree-body-unmount').length,
replacedBodies: s.replacedBodies,
transitions,
}
})
const phaseRequests = requests.slice(reqBase).filter(entry => entry.kind !== 'git')
.map(entry => `${entry.kind}[${entry.paths.map(path => path.split('/').pop()).join(',')}]`)
const phaseFrames = sockets.slice(sockBase)
.flatMap(sock => [...sock.sent.map(frame => `sent#${sock.id} ${frame}`), ...sock.recv.map(frame => `recv#${sock.id} ${frame}`)])
const result: Phase = {
phase,
requests: phaseRequests,
sockets: sockets.length - sockBase,
wsFrames: phaseFrames.length,
added: sample?.added ?? -1,
removed: sample?.removed ?? -1,
killedRows: sample?.killedRows ?? -1,
treeBodyReplaced: (sample?.replacedBodies ?? 0) > 0 || (sample?.remounts ?? 0) > 0 ? 1 : 0,
emptyFrames: sample?.emptyFrames ?? -1,
minBigRows: sample?.minBig ?? -1,
transitions: sample?.transitions ?? [],
}
phases.push(result)
console.log(`EXPAND_PHASE ${JSON.stringify(result)}`)
return result
}
// A: the reported gesture — expanding the 40-child directory.
const expand = await runPhase(`A-expand-${BIG}`, async () => {
await dirRow(BIG).click({ position: { x: 8, y: 8 } })
})
// B: idle with the directory open — a watcher feedback loop would show up
// here as requests arriving with nobody touching anything.
const idle = await runPhase('B-idle', null)
// C: a directory written BEHIND the plugin (a build, a formatter, the
// model's own bash): the host notices and the tree re-lists exactly that
// level, in place.
const landed = `landed-${Date.now()}.txt`
const external = await runPhase('C-external-write', async () => {
writeFileSync(join(WORKSPACE_PATH, BIG, landed), 'landed\n')
})
// D: expanding a second directory while the first stays open.
const sibling = await runPhase('D-expand-bravo', async () => {
await dirRow('bravo').click({ position: { x: 8, y: 8 } })
})
// E: collapsing the first one back.
const collapse = await runPhase(`E-collapse-${BIG}`, async () => {
await dirRow(BIG).click({ position: { x: 8, y: 8 } })
})
console.log(`EXPAND_SUMMARY ${JSON.stringify(phases.map(phase => ({
phase: phase.phase,
requests: phase.requests,
newSockets: phase.sockets,
wsFrames: phase.wsFrames,
domAdded: phase.added,
domRemoved: phase.removed,
rowsKilledWithSubtrees: phase.killedRows,
treeBodyReplaced: phase.treeBodyReplaced,
minBigRows: phase.minBigRows,
emptyFramesAfterRowsUp: phase.emptyFrames,
rowTransitions: phase.transitions,
})), null, 1)}`)
// A1: the toggle's own request names the toggled level only.
expect(
expand.requests[0],
'expanding a folder must ask for that folder, not the whole visible set',
).toBe(`fs.trees[${BIG}]`)
expect(sibling.requests[0], 'expanding a sibling must ask for the sibling').toBe('fs.trees[bravo]')
// A2: the tree body survives every toggle. A replaced body unmounts the
// explorer — level cache, scroll position and directory watcher with it —
// and a reopened socket re-announces its whole watch set.
for (const phase of [expand, sibling, collapse]) {
expect(phase.treeBodyReplaced, `${phase.phase}: the tab body must not be replaced`).toBe(0)
expect(phase.sockets, `${phase.phase}: the watch socket must not be re-opened`).toBe(0)
}
// A3: rows on screen never drop to zero.
expect(expand.emptyFrames, 'the expand must not blank the level').toBe(0)
expect(external.emptyFrames, 'a live refresh must not blank the level').toBe(0)
expect(external.minBigRows, 'the live refresh must keep the level on screen').toBe(BIG_CHILDREN)
// …and neither the idle folder nor the collapse re-lists anything.
expect(idle.requests, 'an idle open folder must not re-list').toEqual([])
expect(collapse.requests, 'collapsing must not re-list anything').toEqual([])
expect(collapse.killedRows, 'collapsing only removes the level it closes').toBe(0)
})