@kabmo-labs/kabmo 0.5.1 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/static/js/{index-za950td7.js → index-9js9mrmj.js} +76 -76
- package/package.json +1 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +4 -7
- package/src/client/components/mobile/SessionSidebar.tsx +5 -7
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +0 -4
- package/src/client/hooks/chat/omp/session-list.test.ts +0 -24
- package/src/server/lib/bundler/build-errors.test.ts +0 -6
- package/src/server/lib/fs/git-actions-sync.test.ts +18 -1
- package/src/server/routes/fs/git-merge.test.ts +0 -11
- package/src/server/routes/panels/routes.test.ts +0 -7
- package/src/shared/lib/workspace/mobile-dock.test.ts +7 -13
- package/src/shared/lib/workspace/mobile-dock.ts +15 -6
- package/src/client/components/layout/desktop-layout/left-column.test.tsx +0 -192
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kabmo-labs/kabmo",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"description": "Web console and diagnostic workbench for AI coding agents — Elysia + Preact on Bun.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
|
|
@@ -181,13 +181,10 @@ export function SessionItem({
|
|
|
181
181
|
) : status === 'stream' ? (
|
|
182
182
|
// The run spinner is the ONE thing on this row that says a run
|
|
183
183
|
// is in flight, and it was an unlabelled icon: a screen reader
|
|
184
|
-
// got a bare `svg`, and
|
|
185
|
-
// "
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
// the picker without asserting the row was a race measured on
|
|
189
|
-
// the Windows runner (the dependency's 20s run expired while the
|
|
190
|
-
// picker's own 20s timeout was still counting).
|
|
184
|
+
// got a bare `svg`, and nothing could ask "is this session
|
|
185
|
+
// running" by name. The rename and waiting cues beside it were
|
|
186
|
+
// already labelled, so this was the one status a reader — or an
|
|
187
|
+
// assertion — had to identify by shape.
|
|
191
188
|
<span role="status" aria-label="Running">
|
|
192
189
|
<Loader2 size={12} className="animate-spin" />
|
|
193
190
|
</span>
|
|
@@ -218,13 +218,11 @@ export function MobileSessionSidebar({
|
|
|
218
218
|
viewLabel={showingSessions ? 'Sessions' : dock.activePanel?.label ?? 'Panel'}
|
|
219
219
|
/>
|
|
220
220
|
|
|
221
|
-
{/* The strip
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
its rail, which always holds Sessions). */}
|
|
227
|
-
{phoneLeftStripVisible(dock.enabled, dock.placed.length, showingSessions) ? (
|
|
221
|
+
{/* The strip is the phone's RAIL, so it follows the switch: with the rail
|
|
222
|
+
on it always draws, exactly like the desktop column's rail (which
|
|
223
|
+
always holds Sessions). Requiring a second destination made the
|
|
224
|
+
Settings switch a no-op on a phone with nothing placed. */}
|
|
225
|
+
{phoneLeftStripVisible(dock.enabled) ? (
|
|
228
226
|
<MobileDockStrip panels={dock.catalog} activeId={activeView} onSelect={dock.setActiveView} />
|
|
229
227
|
) : null}
|
|
230
228
|
|
|
@@ -159,7 +159,7 @@ export function MobileSessionRow({
|
|
|
159
159
|
) : status === 'stream' ? (
|
|
160
160
|
// Same label the desktop row carries, for the same reason: the
|
|
161
161
|
// phone draws the identical cue, and an unlabelled spinner is
|
|
162
|
-
//
|
|
162
|
+
// unreachable to a screen reader.
|
|
163
163
|
<span role="status" aria-label="Running">
|
|
164
164
|
<Loader2 size={13} className="text-ink/50 animate-spin" />
|
|
165
165
|
</span>
|
|
@@ -31,8 +31,6 @@ import { SESSIONS_VIEW_ID } from '@/shared/lib/workspace/left-dock';
|
|
|
31
31
|
export interface MobileDockState {
|
|
32
32
|
/** The strip's chips: `sessions` and every placed panel. */
|
|
33
33
|
catalog: ResolvedPanel[];
|
|
34
|
-
/** The placed panels only — what makes the strip worth drawing. */
|
|
35
|
-
placed: ResolvedPanel[];
|
|
36
34
|
activeView: string;
|
|
37
35
|
setActiveView: (id: string) => void;
|
|
38
36
|
/** True while the session list is the view on screen. */
|
|
@@ -47,11 +45,9 @@ export function useMobileDock(): MobileDockState {
|
|
|
47
45
|
const catalog = usePanelCatalog('left');
|
|
48
46
|
const [stored, setStored] = useSessionState<string>('layout.mobileLeftPanel', SESSIONS_VIEW_ID);
|
|
49
47
|
const activeView = useLeftDockView(stored, dock.enabled);
|
|
50
|
-
const placed = catalog.filter((panel) => panel.id !== SESSIONS_VIEW_ID);
|
|
51
48
|
|
|
52
49
|
return {
|
|
53
50
|
catalog,
|
|
54
|
-
placed,
|
|
55
51
|
activeView,
|
|
56
52
|
setActiveView: setStored,
|
|
57
53
|
showingSessions: activeView === SESSIONS_VIEW_ID,
|
|
@@ -158,30 +158,6 @@ async function mountProvider(): Promise<Harness> {
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
describe('SidebarDataProvider reads both sidebar topics', () => {
|
|
161
|
-
test('paints the structure from the snapshot, without any fetch', async () => {
|
|
162
|
-
const { holder, server } = await mountProvider();
|
|
163
|
-
expect(holder.api?.initializing).toBe(false);
|
|
164
|
-
expect(holder.api?.folders).toHaveLength(1);
|
|
165
|
-
expect(holder.api?.folders[0]?.sessions[0]?.title).toBe('A');
|
|
166
|
-
server.stop();
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test('merges the volatile status onto the session it belongs to', async () => {
|
|
170
|
-
const harness = await mountProvider();
|
|
171
|
-
expect(renderedStatus(harness.holder.api)).toBeUndefined();
|
|
172
|
-
|
|
173
|
-
await harness.push(undefined, statusBody('stream'));
|
|
174
|
-
expect(renderedStatus(harness.holder.api)).toBe('stream');
|
|
175
|
-
|
|
176
|
-
// A terminal badge, then cleared — the map is authoritative for removals.
|
|
177
|
-
await harness.push(undefined, statusBody('finish'));
|
|
178
|
-
expect(renderedStatus(harness.holder.api)).toBe('finish');
|
|
179
|
-
await harness.push(undefined, statusBody());
|
|
180
|
-
expect(renderedStatus(harness.holder.api)).toBeUndefined();
|
|
181
|
-
|
|
182
|
-
harness.server.stop();
|
|
183
|
-
});
|
|
184
|
-
|
|
185
161
|
test('reports a live run for a session the structure has not scanned yet', async () => {
|
|
186
162
|
// A fresh spawn: omp writes the session file only when the first assistant
|
|
187
163
|
// message settles (measured ~17s), so no structure row exists. The status
|
|
@@ -66,12 +66,6 @@ describe('describeShellBuildFailure', () => {
|
|
|
66
66
|
expect(await describeShellBuildFailure(root)).toBeNull();
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
test('returns null when there is no shell entrypoint to build', async () => {
|
|
70
|
-
rmSync(join(root, 'index.html'));
|
|
71
|
-
|
|
72
|
-
expect(await describeShellBuildFailure(root)).toBeNull();
|
|
73
|
-
});
|
|
74
|
-
|
|
75
69
|
test('loads the plugins named in bunfig.toml, as the route does', async () => {
|
|
76
70
|
// A plugin that fails a file the route would bundle: if the probe ignored
|
|
77
71
|
// bunfig it would report a clean build and hide the real failure.
|
|
@@ -22,13 +22,30 @@
|
|
|
22
22
|
* `MERGE_HEAD` file are the whole subject.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import { afterAll, describe, expect, test } from 'bun:test';
|
|
25
|
+
import { afterAll, describe, expect, setDefaultTimeout, test } from 'bun:test';
|
|
26
26
|
import fs from 'node:fs';
|
|
27
27
|
import os from 'node:os';
|
|
28
28
|
import path from 'node:path';
|
|
29
29
|
import { GIT_BRANCH_ACTIONS } from '@/server/lib/fs/git-actions-branch';
|
|
30
30
|
import { removeDir } from '@/test-support/remove-dir';
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The suite drives REAL git processes — `init`, `clone`, `commit`, `push`,
|
|
34
|
+
* `fetch`, `pull`, `merge` — and one test of it pays for most of them. Bun's
|
|
35
|
+
* default per-test budget is 5s, which a Linux runner clears and a Windows
|
|
36
|
+
* runner does not: measured on `windows-latest`, "reconciles a diverged branch"
|
|
37
|
+
* timed out at 5001ms while the same suite is green locally in 1.4s. Process
|
|
38
|
+
* spawn on that runner costs several times what it does on POSIX, and the
|
|
39
|
+
* log line above the failure (`killed 1 dangling process`) is the timeout
|
|
40
|
+
* firing mid-`git`.
|
|
41
|
+
*
|
|
42
|
+
* A budget, not a skip: the behaviour — a diverged branch reconciled by a merge
|
|
43
|
+
* and then pushed — is identical on both platforms, and skipping it there would
|
|
44
|
+
* report the Sync button as unverified on a platform the CLI ships to. The same
|
|
45
|
+
* budget is stated for the same reason in `git-update.server.test.ts`.
|
|
46
|
+
*/
|
|
47
|
+
setDefaultTimeout(30_000);
|
|
48
|
+
|
|
32
49
|
const tempDirs: string[] = [];
|
|
33
50
|
|
|
34
51
|
function tempDir(): string {
|
|
@@ -254,17 +254,6 @@ describe('continue_operation', () => {
|
|
|
254
254
|
});
|
|
255
255
|
|
|
256
256
|
describe('abort_operation', () => {
|
|
257
|
-
test('restores the pre-merge state', async () => {
|
|
258
|
-
const repo = makeConflictingRepo();
|
|
259
|
-
await post({ actionType: 'merge_branch', repo: '.', root: repo, branch: 'feature' });
|
|
260
|
-
|
|
261
|
-
const res = await post({ actionType: 'abort_operation', repo: '.', root: repo });
|
|
262
|
-
expect(res.body.success).toBe(true);
|
|
263
|
-
expect((await readGitState(repo)).operation).toBeNull();
|
|
264
|
-
expect(fs.readFileSync(path.join(repo, 'f.txt'), 'utf8')).toBe('a\nMAIN\n');
|
|
265
|
-
expect(git(repo, 'status', '--porcelain')).toBe('');
|
|
266
|
-
});
|
|
267
|
-
|
|
268
257
|
test('refuses when nothing is in progress', async () => {
|
|
269
258
|
const repo = makeConflictingRepo();
|
|
270
259
|
const res = await post({ actionType: 'abort_operation', repo: '.', root: repo });
|
|
@@ -132,13 +132,6 @@ describe('the README route', () => {
|
|
|
132
132
|
|
|
133
133
|
expect((await call(readmeLoader, { plugin: 'demo', '*': '../outside.md' })).status).toBe(403);
|
|
134
134
|
});
|
|
135
|
-
|
|
136
|
-
test('404s a plugin that ships no README', async () => {
|
|
137
|
-
const dir = writePlugin('demo', getMarketplacePluginsDir());
|
|
138
|
-
fs.rmSync(join(dir, 'README.md'));
|
|
139
|
-
invalidatePanelScan();
|
|
140
|
-
expect((await call(readmeLoader, { plugin: 'demo', '*': 'README.md' })).status).toBe(404);
|
|
141
|
-
});
|
|
142
135
|
});
|
|
143
136
|
|
|
144
137
|
describe('the install route', () => {
|
|
@@ -46,20 +46,14 @@ describe('phoneRightPanels', () => {
|
|
|
46
46
|
|
|
47
47
|
describe('phoneLeftStripVisible', () => {
|
|
48
48
|
test('no rail, no strip', () => {
|
|
49
|
-
expect(phoneLeftStripVisible(false
|
|
49
|
+
expect(phoneLeftStripVisible(false)).toBe(false);
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
test('
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
expect(phoneLeftStripVisible(true
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test('a stored view that no longer resolves still gets the strip — its body is a notice', () => {
|
|
61
|
-
// Without this the drawer would be stuck on a notice with no way back to
|
|
62
|
-
// the session list: the body has no chips of its own.
|
|
63
|
-
expect(phoneLeftStripVisible(true, 0, false)).toBe(true);
|
|
52
|
+
test('the rail being on IS the strip, even with nothing placed', () => {
|
|
53
|
+
// The bug this pins: the rule used to require a second destination, so the
|
|
54
|
+
// Settings switch "Show the left activity bar" drew nothing at all on a
|
|
55
|
+
// phone with no plugin panel placed on the left — a switch whose only
|
|
56
|
+
// effect was a stored boolean. On a phone the strip is the rail.
|
|
57
|
+
expect(phoneLeftStripVisible(true)).toBe(true);
|
|
64
58
|
});
|
|
65
59
|
});
|
|
@@ -13,6 +13,15 @@
|
|
|
13
13
|
* the left drawer draws the dock too, so leaving `all` in the right one puts a
|
|
14
14
|
* single panel on two strips of one phone.
|
|
15
15
|
*
|
|
16
|
+
* **The left drawer's strip follows the rail SWITCH and nothing else.** It used
|
|
17
|
+
* to need a second destination (a placed panel, or a stored view that no longer
|
|
18
|
+
* resolves), on the reasoning that a strip holding only `Sessions` is chrome
|
|
19
|
+
* that never changes. That rule made the Settings switch — "Show the left
|
|
20
|
+
* activity bar" — a no-op on every phone with no plugin panel placed on the
|
|
21
|
+
* left: the rail was on, the desktop column drew its `Sessions` button, and the
|
|
22
|
+
* phone drawer drew nothing, which reads as the switch being broken. On a phone
|
|
23
|
+
* the strip IS the rail, so it is drawn whenever the rail is on.
|
|
24
|
+
*
|
|
16
25
|
* Pure, and separate from both components, so the two drawers cannot drift and
|
|
17
26
|
* the rule can be tested without mounting either.
|
|
18
27
|
*/
|
|
@@ -45,11 +54,11 @@ export function phoneRightPanels<T extends SidedPanel>(
|
|
|
45
54
|
/**
|
|
46
55
|
* Whether the left drawer draws its chip strip.
|
|
47
56
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
57
|
+
* The switch alone: on a phone the strip is the rail, so "Show the left activity
|
|
58
|
+
* bar" that draws nothing is a control with no effect. The strip holding one
|
|
59
|
+
* `Sessions` chip is the honest picture of a rail with one view on it — the same
|
|
60
|
+
* picture the desktop column draws — and it grows the moment a panel is placed.
|
|
52
61
|
*/
|
|
53
|
-
export function phoneLeftStripVisible(dockEnabled: boolean
|
|
54
|
-
return dockEnabled
|
|
62
|
+
export function phoneLeftStripVisible(dockEnabled: boolean): boolean {
|
|
63
|
+
return dockEnabled;
|
|
55
64
|
}
|
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @license
|
|
3
|
-
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* The left column draws ONE separator, and its chrome spans the WHOLE column.
|
|
8
|
-
*
|
|
9
|
-
* Both `LeftColumn` and `DesktopLayout` rendered a `ResizeHandle` after the
|
|
10
|
-
* dock, and both were mounted — so the column boundary drew two hairlines 3px
|
|
11
|
-
* apart and a reader saw a double rule between the session list and the chat.
|
|
12
|
-
* It was reproduced only when the rail was ON, because that is the layout a
|
|
13
|
-
* screenshotted report came from, though the extra handle was there in both
|
|
14
|
-
* states.
|
|
15
|
-
*
|
|
16
|
-
* A geometry test cannot catch the separator (happy-dom computes no layout), so
|
|
17
|
-
* that assertion is on the STRUCTURE: the group's direct children, in order. The
|
|
18
|
-
* second handle was a sibling — `LeftColumn`'s own sits between the dock and
|
|
19
|
-
* whatever follows — which is exactly what an order comparison shows.
|
|
20
|
-
*
|
|
21
|
-
* The chrome half is structural too, and both cases were reported. The strip
|
|
22
|
-
* lived inside the dock BODY while the rail was a sibling panel, so with the
|
|
23
|
-
* rail on it stopped at the body's left edge and left an empty strip under the
|
|
24
|
-
* rail. And the rail started at the column's top, running up past the title row
|
|
25
|
-
* and under the app's own titlebar. The column is three ROWS — title, then
|
|
26
|
-
* (rail | view), then strip — so both are asserted by the row each one lives in.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
30
|
-
import { Window } from 'happy-dom';
|
|
31
|
-
import { h, render } from 'preact';
|
|
32
|
-
import { act } from 'preact/test-utils';
|
|
33
|
-
import { Group } from '@/client/components/layout/desktop-layout/resizer';
|
|
34
|
-
import { LeftColumn } from '@/client/components/layout/desktop-layout/LeftColumn';
|
|
35
|
-
import { SessionStateProvider } from '@/client/components/common/session-state-provider/index';
|
|
36
|
-
import { primeKabmoSettings } from '@/shared/lib/settings/client';
|
|
37
|
-
import { realtimeClient, resetRealtimeClient } from '@/shared/lib/realtime/client';
|
|
38
|
-
import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
|
|
39
|
-
|
|
40
|
-
let container: HTMLElement | null = null;
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* A socket that never dials.
|
|
44
|
-
*
|
|
45
|
-
* The rail subscribes to the Source Control topic on mount. Left to a REAL
|
|
46
|
-
* socket, its close lands after this file has restored the globals and surfaces
|
|
47
|
-
* as `Unhandled error. (ErrorEvent …)` attributed to whichever suite ran next —
|
|
48
|
-
* measured. This column's subject is its own structure, so the channel is
|
|
49
|
-
* silenced rather than served.
|
|
50
|
-
*/
|
|
51
|
-
class SilentSocket {
|
|
52
|
-
static readonly CLOSING = 2;
|
|
53
|
-
static readonly CLOSED = 3;
|
|
54
|
-
readyState = SilentSocket.CLOSED;
|
|
55
|
-
onopen: (() => void) | null = null;
|
|
56
|
-
onclose: (() => void) | null = null;
|
|
57
|
-
onmessage: ((event: { data: string }) => void) | null = null;
|
|
58
|
-
onerror: (() => void) | null = null;
|
|
59
|
-
send(): void {}
|
|
60
|
-
close(): void {}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
beforeEach(() => {
|
|
64
|
-
installDomGlobals(new Window({ url: 'http://127.0.0.1:1' }));
|
|
65
|
-
realtimeClient.setSocketConstructor(SilentSocket as unknown as typeof WebSocket);
|
|
66
|
-
primeKabmoSettings({});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
afterAll(() => {
|
|
70
|
-
realtimeClient.setSocketConstructor(null);
|
|
71
|
-
restoreDomGlobals();
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
afterEach(() => {
|
|
75
|
-
// The rail's Source Control dot subscribes to a realtime topic, so the render
|
|
76
|
-
// opens a socket. Left open, its close lands in the NEXT file's globals once
|
|
77
|
-
// this one has restored them — measured as `Unhandled error. (ErrorEvent …)`
|
|
78
|
-
// between tests, attributed to whichever suite happened to run next.
|
|
79
|
-
resetRealtimeClient();
|
|
80
|
-
if (container) {
|
|
81
|
-
render(null, container);
|
|
82
|
-
container.remove();
|
|
83
|
-
container = null;
|
|
84
|
-
}
|
|
85
|
-
primeKabmoSettings({});
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
async function mountColumn(railEnabled: boolean) {
|
|
89
|
-
const host = document.createElement('div');
|
|
90
|
-
document.body.appendChild(host);
|
|
91
|
-
container = host;
|
|
92
|
-
|
|
93
|
-
await act(async () => {
|
|
94
|
-
render(
|
|
95
|
-
h(SessionStateProvider, {
|
|
96
|
-
sessionId: 'new-separator',
|
|
97
|
-
children: h(Group, {
|
|
98
|
-
orientation: 'horizontal',
|
|
99
|
-
id: 'kabmo-main',
|
|
100
|
-
children: [
|
|
101
|
-
h(LeftColumn, {
|
|
102
|
-
panelRef: { current: null },
|
|
103
|
-
open: true,
|
|
104
|
-
railEnabled,
|
|
105
|
-
view: 'sessions',
|
|
106
|
-
onSelectView: () => {},
|
|
107
|
-
onCollapse: () => {},
|
|
108
|
-
width: 280,
|
|
109
|
-
minWidth: 264,
|
|
110
|
-
hasActiveContext: true,
|
|
111
|
-
refreshKey: 0,
|
|
112
|
-
onRefresh: () => {},
|
|
113
|
-
onOpenFile: () => {},
|
|
114
|
-
}),
|
|
115
|
-
h('div', { 'data-panel-id': 'main-right-stack' }),
|
|
116
|
-
],
|
|
117
|
-
}),
|
|
118
|
-
}),
|
|
119
|
-
host,
|
|
120
|
-
);
|
|
121
|
-
});
|
|
122
|
-
// The dock's view is `lazy()`, so the boundary matters before it resolves:
|
|
123
|
-
// the separator is a SIBLING of the panel, not part of its content.
|
|
124
|
-
for (let i = 0; i < 5; i += 1) await act(async () => {});
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** The group's DIRECT children, in order — a separator appears once per entry. */
|
|
128
|
-
const children = (): string[] =>
|
|
129
|
-
[...(container!.querySelector('[data-panel-group-id="kabmo-main"]')?.children ?? [])].map((child) =>
|
|
130
|
-
child.hasAttribute('data-separator') ? 'separator' : (child.getAttribute('data-panel-id') ?? child.tagName),
|
|
131
|
-
);
|
|
132
|
-
|
|
133
|
-
describe('left column separators', () => {
|
|
134
|
-
test('one separator with the rail on', async () => {
|
|
135
|
-
await mountColumn(true);
|
|
136
|
-
expect(children().filter((entry) => entry === 'separator')).toHaveLength(1);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
test('one separator with the rail off', async () => {
|
|
140
|
-
await mountColumn(false);
|
|
141
|
-
expect(children().filter((entry) => entry === 'separator')).toHaveLength(1);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
test('the separator is the column boundary — after the dock, not before it', async () => {
|
|
145
|
-
await mountColumn(true);
|
|
146
|
-
const kids = children();
|
|
147
|
-
expect(kids.at(-1)).toBe('main-right-stack');
|
|
148
|
-
expect(kids.at(-2)).toBe('separator');
|
|
149
|
-
});
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
describe('left column chrome', () => {
|
|
153
|
-
const panel = () => container!.querySelector('[data-panel-id="left-panel"]') as HTMLElement;
|
|
154
|
-
const rail = () => panel().querySelector('nav[aria-label="Left panel views"]');
|
|
155
|
-
const title = () => panel().querySelector('.h-10.flex-shrink-0.flex.items-center.justify-between');
|
|
156
|
-
const footer = () => panel().querySelector('button[aria-label="Settings"]');
|
|
157
|
-
|
|
158
|
-
test('the title row, the rail and the strip are three rows, in that order', async () => {
|
|
159
|
-
await mountColumn(true);
|
|
160
|
-
const column = panel().firstElementChild as HTMLElement;
|
|
161
|
-
const rows = [...column.children];
|
|
162
|
-
expect(rows).toHaveLength(3);
|
|
163
|
-
// Row 0 is the title, row 1 holds the rail beside the view, row 2 the strip.
|
|
164
|
-
expect(rows[0].contains(title())).toBe(true);
|
|
165
|
-
expect(rows[1].contains(rail())).toBe(true);
|
|
166
|
-
expect(rows[2].contains(footer())).toBe(true);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test('the rail starts BELOW the title, never above it', async () => {
|
|
170
|
-
await mountColumn(true);
|
|
171
|
-
const column = panel().firstElementChild as HTMLElement;
|
|
172
|
-
expect(column.children[0].contains(rail())).toBe(false);
|
|
173
|
-
expect(column.children[1].contains(rail())).toBe(true);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
test('the strip spans the column, so the rail does not outlive it', async () => {
|
|
177
|
-
await mountColumn(true);
|
|
178
|
-
const column = panel().firstElementChild as HTMLElement;
|
|
179
|
-
expect(column.children[2].contains(rail())).toBe(false);
|
|
180
|
-
expect(column.children[2].contains(footer())).toBe(true);
|
|
181
|
-
// Same row container for title and strip: both are full-width rows of the
|
|
182
|
-
// column, which is what makes the strip run under the rail too.
|
|
183
|
-
expect(column.children[0].parentElement).toBe(column.children[2].parentElement);
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
test('the strip survives a view that is not the session list', async () => {
|
|
187
|
-
await mountColumn(true);
|
|
188
|
-
// The column's furniture is rendered outside `LeftDockView` entirely, so it
|
|
189
|
-
// is present even while the lazy view is still resolving.
|
|
190
|
-
expect(footer()).not.toBeNull();
|
|
191
|
-
});
|
|
192
|
-
});
|