Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
207 lines
7.6 KiB
TypeScript
207 lines
7.6 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import test from 'node:test';
|
|
import { fileURLToPath } from 'node:url';
|
|
import path from 'node:path';
|
|
|
|
import {
|
|
buildItemIndexToVisualIndexMap,
|
|
clampListIndex,
|
|
getFixedSizeVirtualWindow,
|
|
stepListIndex,
|
|
} from './ui/virtualListMath.ts';
|
|
import { getQuickSwitcherVisualRowHeight } from './QuickSwitcher.tsx';
|
|
|
|
const root = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
const read = (relativePath: string) =>
|
|
readFileSync(path.join(root, relativePath), 'utf8');
|
|
|
|
test('SelectHostPanelContent virtualizes with listbox keyboard model', () => {
|
|
const source = read('SelectHostPanelContent.tsx');
|
|
assert.match(source, /VariableSizeVirtualList/);
|
|
assert.match(source, /data-host-picker-virtual="select-host"/);
|
|
assert.match(source, /role="listbox"/);
|
|
assert.match(source, /handleListKeyDown/);
|
|
assert.match(source, /stepListIndex/);
|
|
// Screen readers need active option linkage while focus stays on the listbox.
|
|
assert.match(source, /aria-activedescendant=\{activeDescendantId\}/);
|
|
assert.match(source, /optionDomId/);
|
|
// Navigable-index IDs avoid collisions when group paths sanitize identically.
|
|
assert.match(source, /id=\{navIndex >= 0 \? optionDomId\(navIndex\) : undefined\}/);
|
|
assert.match(source, /\$\{listboxId\}-opt-\$\{navIndex\}/);
|
|
// Virtual rows must O(1)-lookup nav indices, not findIndex the full navigable list.
|
|
assert.match(source, /navigableIndexByKey/);
|
|
assert.match(source, /navigableIndexByKey\.get\(row\.key\)/);
|
|
assert.doesNotMatch(source, /navigable\.findIndex/);
|
|
// Selected hosts must still show the keyboard active ring.
|
|
assert.match(source, /isSelected \? 'bg-muted' : isActive \? 'bg-primary\/10' : 'hover:bg-muted\/70'/);
|
|
assert.match(source, /isActive && 'ring-1 ring-primary\/40'/);
|
|
assert.doesNotMatch(
|
|
source,
|
|
/isSelected \? 'bg-muted' : isActive \? 'bg-primary\/10 ring-1 ring-primary\/40'/,
|
|
);
|
|
assert.doesNotMatch(source, /filteredHosts\.map\(\(host\) =>/);
|
|
// One listbox tab stop — not per-host tabIndex under virtualization.
|
|
assert.match(source, /role="listbox"[\s\S]*tabIndex=\{0\}/);
|
|
assert.doesNotMatch(source, /data-host-id=\{host\.id\}[\s\S]{0,120}tabIndex=\{0\}/);
|
|
// Path/filter changes must reset the cursor; length-only shrinks still clamp.
|
|
assert.match(
|
|
source,
|
|
/setActiveNavIndex\(0\);\s*\}, \[currentPath, searchQuery, selectedTags, sortMode\]/,
|
|
);
|
|
assert.match(
|
|
source,
|
|
/setActiveNavIndex\(\(prev\) => clampListIndex\(prev, navigable\.length\)\);\s*\}, \[navigable\.length\]/,
|
|
);
|
|
// Multi-select listbox must advertise multi-selection to AT.
|
|
assert.match(source, /aria-multiselectable=\{multiSelect \|\| undefined\}/);
|
|
});
|
|
|
|
test('QuickSwitcher virtualizes categorized rows and clamps empty-list keyboard', () => {
|
|
const source = read('QuickSwitcher.tsx');
|
|
assert.match(source, /VariableSizeVirtualList/);
|
|
assert.match(source, /data-host-picker-virtual="quick-switcher"/);
|
|
assert.match(source, /stepListIndex/);
|
|
assert.match(source, /clampListIndex/);
|
|
// Two-line plugin rows need a taller virtual slot than QS_ROW_HEIGHT.
|
|
assert.match(source, /QS_PLUGIN_ROW_HEIGHT/);
|
|
// Fixed-height slots must clip/truncate so long titles never overlap the next row.
|
|
assert.match(source, /overflow-hidden/);
|
|
assert.match(source, /max-w-\[12rem\] shrink-0 truncate/);
|
|
// List scroller needs a definite max height under max-h-only popup chrome.
|
|
assert.match(source, /max-h-\[min\(360px,calc\(100vh-14rem\)\)\]/);
|
|
assert.doesNotMatch(source, /results\.map\(\(host\) =>/);
|
|
// Must not use bare length-1 which yields -1 on empty lists.
|
|
assert.doesNotMatch(
|
|
source,
|
|
/setSelectedIndex\(\(prev\) => Math\.min\(prev \+ 1, flatItems\.length - 1\)\)/,
|
|
);
|
|
});
|
|
|
|
test('AddToWorkspaceDialog virtualizes and clamps selection on shrink', () => {
|
|
const source = read('workspace/AddToWorkspaceDialog.tsx');
|
|
assert.match(source, /VariableSizeVirtualList/);
|
|
assert.match(source, /data-host-picker-virtual="add-workspace"/);
|
|
assert.match(source, /clampListIndex\(prev, items\.length\)/);
|
|
assert.match(source, /onClick=\{\(\) => handleTargetClick\(idx, LOCAL_ITEM_ID\)\}/);
|
|
assert.match(source, /onClick=\{\(\) => handleTargetClick\(idx, host\.id\)\}/);
|
|
// Long group paths must not wrap out of the fixed virtual row.
|
|
assert.match(source, /max-w-\[12rem\] truncate text-\[11px\] text-muted-foreground/);
|
|
assert.match(source, /max-h-\[min\(360px,calc\(100vh-14rem\)\)\]/);
|
|
assert.doesNotMatch(source, /filteredHosts\.map\(\(host, i\) =>/);
|
|
});
|
|
|
|
test('CreateWorkspaceDialog virtualizes selectable hosts', () => {
|
|
const source = read('CreateWorkspaceDialog.tsx');
|
|
assert.match(source, /FixedSizeVirtualList/);
|
|
assert.match(source, /data-host-picker-virtual="create-workspace"/);
|
|
assert.doesNotMatch(source, /filteredHosts\.map\(host =>/);
|
|
});
|
|
|
|
test('FixedSizeVirtualList uses shared window math (render path clamp)', () => {
|
|
const source = read('ui/FixedSizeVirtualList.tsx');
|
|
assert.match(source, /getFixedSizeVirtualWindow/);
|
|
assert.match(source, /effectiveScrollTop/);
|
|
});
|
|
|
|
test('shipped virtual window math proves large lists only render a viewport slice', () => {
|
|
const window = getFixedSizeVirtualWindow({
|
|
itemCount: 8_000,
|
|
itemHeight: 52,
|
|
scrollTop: 12_000,
|
|
viewportHeight: 300,
|
|
overscan: 8,
|
|
});
|
|
const rendered = window.endIndex - window.startIndex;
|
|
assert.ok(rendered > 0);
|
|
assert.ok(rendered < 80);
|
|
assert.ok(window.startIndex > 0);
|
|
assert.ok(window.endIndex < 8_000);
|
|
});
|
|
|
|
test('shipped keyboard helpers block empty-list ArrowDown crash path', () => {
|
|
// Reproduce the review finding: empty list + ArrowDown must not yield -1.
|
|
let index = 0;
|
|
index = stepListIndex(index, 0, 1);
|
|
assert.equal(index, 0);
|
|
index = clampListIndex(index, 0);
|
|
assert.equal(index, 0);
|
|
// List repopulates with 5 items — index stays valid for Enter.
|
|
index = clampListIndex(index, 5);
|
|
assert.equal(index, 0);
|
|
});
|
|
|
|
test('shipped visual index map matches picker header/item interleaving', () => {
|
|
// Mirrors QuickSwitcher / SFTP / AddToWorkspace visual row shape.
|
|
const visual = [
|
|
{ kind: 'header' },
|
|
{ kind: 'item' },
|
|
{ kind: 'item' },
|
|
{ kind: 'header' },
|
|
{ kind: 'item' },
|
|
];
|
|
const map = buildItemIndexToVisualIndexMap(visual);
|
|
assert.deepEqual([...map.entries()], [[0, 1], [1, 2], [2, 4]]);
|
|
});
|
|
|
|
test('QuickSwitcher plugin rows use a taller virtual slot than single-line hosts', () => {
|
|
assert.equal(
|
|
getQuickSwitcherVisualRowHeight({ kind: 'header', key: 'h', label: 'Hosts' }),
|
|
32,
|
|
);
|
|
assert.equal(
|
|
getQuickSwitcherVisualRowHeight({
|
|
kind: 'item',
|
|
key: 'host:1',
|
|
itemIndex: 0,
|
|
item: { type: 'host', id: '1' },
|
|
}),
|
|
44,
|
|
);
|
|
// Two-line plugin chrome must not share the 44px host slot (overflow/overlap).
|
|
assert.equal(
|
|
getQuickSwitcherVisualRowHeight({
|
|
kind: 'item',
|
|
key: 'plugin:1',
|
|
itemIndex: 1,
|
|
item: {
|
|
type: 'plugin-command',
|
|
id: 'p1',
|
|
commandId: 'cmd',
|
|
title: 'Run',
|
|
pluginTitle: 'Plugin',
|
|
},
|
|
}),
|
|
56,
|
|
);
|
|
assert.equal(
|
|
getQuickSwitcherVisualRowHeight({
|
|
kind: 'item',
|
|
key: 'view:1',
|
|
itemIndex: 2,
|
|
item: { type: 'plugin-view', id: 'v1', title: 'View', pluginTitle: 'Plugin' },
|
|
}),
|
|
56,
|
|
);
|
|
assert.ok(
|
|
getQuickSwitcherVisualRowHeight({
|
|
kind: 'item',
|
|
key: 'plugin:1',
|
|
itemIndex: 1,
|
|
item: {
|
|
type: 'plugin-command',
|
|
id: 'p1',
|
|
commandId: 'cmd',
|
|
title: 'Run',
|
|
pluginTitle: 'Plugin',
|
|
},
|
|
}) > getQuickSwitcherVisualRowHeight({
|
|
kind: 'item',
|
|
key: 'host:1',
|
|
itemIndex: 0,
|
|
item: { type: 'host', id: '1' },
|
|
}),
|
|
);
|
|
});
|